67 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
e503d69235 feat(locale/pl): update polish locale 2026-03-20 13:22:39 +01:00
57ec3162d0 feat(locale/en): update english locale 2026-03-20 13:22:39 +01:00
c4e9ec5484 feat(kursy/urls.py): include purchase urls 2026-03-20 13:18:39 +01:00
c8732a05cb chore(settings/base.py): add purchase to INSTALLED_APPS 2026-03-20 13:18:07 +01:00
6810e540e5 feat(purchase/views.py): add purchase and refund views 2026-03-20 13:17:47 +01:00
21500e0f10 feat(purchase/urls.py): add purchase/urls.py 2026-03-20 13:17:18 +01:00
be42d71bb8 feat(course_page.html): add refund button and make purchase button work 2026-03-20 13:16:36 +01:00
b5e9e1ec66 feat(home/models/pages.py): add mock purchase login and auto group creation 2026-03-20 13:16:00 +01:00
d575c836e9 feat(purchase/models.py): handle mock refunds 2026-03-20 13:14:03 +01:00
84a6c4cf5e feat(purchase/): add purchase app 2026-03-20 12:03:57 +01:00
e46f034d9e refactor(header.html): use 'Calendar' instead of 'Course Calendar' as it's shorter 2026-03-19 18:18:04 +01:00
dc7e34f5b6 feat(locale/en): update english translations 2026-03-19 18:16:20 +01:00
f002651e2a feat(locale/pl): update polish translations 2026-03-19 18:16:09 +01:00
c789eeb4ff feat(header.html): make header sticky on desktop 2026-03-19 18:11:14 +01:00
acb6ea58ce chore(templates/welcome_page.html): remove unused template 2026-03-19 17:58:56 +01:00
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
1d0fd27e04 chore(migrations/0017): add ChatMessage 2026-03-18 10:24:41 +01:00
ac31336acc feat(kusry/urls.py): include home urls 2026-03-18 10:24:14 +01:00
82514a9418 feat(home/urls.py): add home urls 2026-03-18 10:23:27 +01:00
b599ac6fa3 feat(home/views.py): add chat views 2026-03-18 10:22:58 +01:00
fd6209470b feat(wagtail_hooks.py): add admin chat dashboard to wagtail admin menu 2026-03-18 10:04:49 +01:00
de48747884 feat(templates/chat): add chat-related templates 2026-03-18 10:04:07 +01:00
684871833a refactor(models/__init__.py): correct ChatMessage import 2026-03-18 09:48:07 +01:00
ef69b99068 refactor(chat_message.py): rename Message -> ChatMessage 2026-03-18 09:46:10 +01:00
88c797e4b0 chore(models): import Message model 2026-03-17 13:48:20 +01:00
a0819a6552 feat(models/message.py): add Message model 2026-03-17 13:47:55 +01:00
e4add89ba8 chore(tailwind): remove daisyui 2026-03-17 13:33:20 +01:00
7cbec4fc9c refactor(models): split models.py into submodules 2026-03-17 13:27:23 +01:00
899ed539c4 feat(locale/pl): update polish translations 2026-03-17 12:52:43 +01:00
c22ef183f8 feat(locale/en): update english translations 2026-03-17 12:52:32 +01:00
f95a4915bd feat(templates/module_lesson_page.html): add ModuleLessonPage template 2026-03-17 12:46:46 +01:00
42830dbc45 feat(templates/course_module_page.html): add lesson list 2026-03-17 12:46:05 +01:00
fd537c82fc chore(migrations/0016): add ModuleLessonPage 2026-03-17 12:34:47 +01:00
a2a38dbc6d feat(home/models.py): add ModuleLessonPage model 2026-03-17 12:34:13 +01:00
a44002b714 feat(home/models.py): only allow CourseModulePages to be created under CoursePages 2026-03-17 12:32:37 +01:00
47 changed files with 1391 additions and 198 deletions

View File

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

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-16 12:38+0000\n"
"POT-Creation-Date: 2026-03-20 12:18+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,36 +18,121 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: home/templates/home/course_page.html:26
msgid "Modules"
#: home/templates/chat/admin/admin_chat.html:5
msgid "Chat with"
msgstr ""
#: home/templates/chat/admin/admin_chat.html:10
msgid "Admin Chat View"
msgstr ""
#: home/templates/chat/admin/admin_chat.html:11
msgid ""
"This is the admin view of the chat. Here you can manage conversations and "
"monitor user interactions."
msgstr ""
#: home/templates/chat/admin/admin_chat.html:19
#: home/templates/chat/user_chat.html:18
msgid "No messages found."
msgstr ""
#: home/templates/chat/admin/admin_chat.html:24
#: home/templates/chat/user_chat.html:23
msgid "Type your message here..."
msgstr ""
#: home/templates/chat/admin/admin_chat.html:25
#: home/templates/chat/user_chat.html:24
msgid "Send"
msgstr ""
#: home/templates/chat/admin/admin_chat_dashboard.html:5
#: home/templates/chat/user_chat.html:5
msgid "Chat"
msgstr ""
#: home/templates/chat/admin/admin_chat_dashboard.html:10
msgid "Admin Chat Dashboard"
msgstr ""
#: home/templates/chat/admin/admin_chat_dashboard.html:19
msgid "No active chats found."
msgstr ""
#: home/templates/chat/user_chat.html:9
msgid "Chat with Support"
msgstr ""
#: home/templates/chat/user_chat.html:10
msgid ""
"This is the user chat interface. Here you can communicate with our support "
"team for assistance."
msgstr ""
#: home/templates/home/course_index_page.html:4
#: home/templates/home/course_index_page.html:10
msgid "Courses"
msgstr ""
#: home/templates/home/course_index_page.html:12
msgid "Purchased Courses"
msgstr ""
#: home/templates/home/course_index_page.html:22
msgid "Purchased"
msgstr ""
#: home/templates/home/course_index_page.html:32
msgid "Available Courses"
msgstr ""
#: home/templates/home/course_index_page.html:42
msgid "Not Purchased"
msgstr ""
#: home/templates/home/course_module_page.html:21
msgid "Lessons"
msgstr ""
#: home/templates/home/course_module_page.html:28
msgid "No lessons yet."
msgstr ""
#: home/templates/home/course_page.html:31
msgid "Refund Purchase"
msgstr ""
#: home/templates/home/course_page.html:33
msgid "Modules"
msgstr ""
#: home/templates/home/course_page.html:40
msgid "No modules yet."
msgstr ""
#: home/templates/home/course_page.html:39
#: home/templates/home/course_page.html:46
msgid ""
"You need to be logged in to access this course. Please log in or sign up to "
"view the modules."
msgstr ""
#: home/templates/home/course_page.html:40
#: home/templates/home/course_page.html:47
#: home/templates/home/event_page.html:40
msgid "Login"
msgstr ""
#: home/templates/home/course_page.html:41
#: home/templates/home/course_page.html:48
#: home/templates/home/event_page.html:43
msgid "Sign Up"
msgstr ""
#: home/templates/home/course_page.html:46
#: home/templates/home/course_page.html:53
msgid ""
"You don't have access to this course. Please purchase it to view the modules."
msgstr ""
#: home/templates/home/course_page.html:47
#: home/templates/home/course_page.html:54
msgid "Purchase Course"
msgstr ""
@@ -83,46 +168,3 @@ msgstr ""
#: home/templates/home/event_page.html:54
msgid "Sign Up for Event"
msgstr ""
#: home/templates/home/welcome_page.html:6
msgid "Visit the Wagtail website"
msgstr ""
#: home/templates/home/welcome_page.html:15
msgid "View the release notes"
msgstr ""
#: home/templates/home/welcome_page.html:27
msgid "Welcome to your new Wagtail site!"
msgstr ""
#: home/templates/home/welcome_page.html:28
msgid ""
"Please feel free to <a href=\"https://github.com/wagtail/wagtail/wiki/"
"Slack\">join our community on Slack</a>, or get started with one of the "
"links below."
msgstr ""
#: home/templates/home/welcome_page.html:35
msgid "Wagtail Documentation"
msgstr ""
#: home/templates/home/welcome_page.html:36
msgid "Topics, references, & how-tos"
msgstr ""
#: home/templates/home/welcome_page.html:42
msgid "Tutorial"
msgstr ""
#: home/templates/home/welcome_page.html:43
msgid "Build your first Wagtail site"
msgstr ""
#: home/templates/home/welcome_page.html:49
msgid "Admin Interface"
msgstr ""
#: home/templates/home/welcome_page.html:50
msgid "Create your superuser first!"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-16 12:38+0000\n"
"POT-Creation-Date: 2026-03-20 12:18+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -20,43 +20,134 @@ msgstr ""
"(n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && "
"n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
#: home/templates/home/course_page.html:26
#: home/templates/chat/admin/admin_chat.html:5
msgid "Chat with"
msgstr "Czat z"
#: home/templates/chat/admin/admin_chat.html:10
msgid "Admin Chat View"
msgstr "Widok administratora czatu"
#: home/templates/chat/admin/admin_chat.html:11
msgid ""
"This is the admin view of the chat. Here you can manage conversations and "
"monitor user interactions."
msgstr ""
#: home/templates/chat/admin/admin_chat.html:19
#: home/templates/chat/user_chat.html:18
msgid "No messages found."
msgstr "Brak wiadomości."
#: home/templates/chat/admin/admin_chat.html:24
#: home/templates/chat/user_chat.html:23
msgid "Type your message here..."
msgstr "Wiadomość..."
#: home/templates/chat/admin/admin_chat.html:25
#: home/templates/chat/user_chat.html:24
msgid "Send"
msgstr "Wyślij"
#: home/templates/chat/admin/admin_chat_dashboard.html:5
#: home/templates/chat/user_chat.html:5
msgid "Chat"
msgstr "Czat"
#: home/templates/chat/admin/admin_chat_dashboard.html:10
msgid "Admin Chat Dashboard"
msgstr "Panel administratora czatu"
#: home/templates/chat/admin/admin_chat_dashboard.html:19
msgid "No active chats found."
msgstr "Brak aktywnych czatów."
#: home/templates/chat/user_chat.html:9
msgid "Chat with Support"
msgstr "Czat z administracją"
#: home/templates/chat/user_chat.html:10
msgid ""
"This is the user chat interface. Here you can communicate with our support "
"team for assistance."
msgstr ""
"To jest interfejs czatu dla użytkowników. Tutaj możesz komunikować się z "
"naszym zespołem wsparcia w celu uzyskania pomocy."
#: home/templates/home/course_index_page.html:4
#: home/templates/home/course_index_page.html:10
msgid "Courses"
msgstr "Kursy"
#: home/templates/home/course_index_page.html:12
msgid "Purchased Courses"
msgstr "Zakupione kursy"
#: home/templates/home/course_index_page.html:22
msgid "Purchased"
msgstr "Zakupiony"
#: home/templates/home/course_index_page.html:32
msgid "Available Courses"
msgstr "Dostępne kursy"
#: home/templates/home/course_index_page.html:42
msgid "Not Purchased"
msgstr "Niezakupiony"
#: home/templates/home/course_module_page.html:21
msgid "Lessons"
msgstr "Lekcje"
#: home/templates/home/course_module_page.html:28
msgid "No lessons yet."
msgstr "Brak lekcji."
#: home/templates/home/course_page.html:31
msgid "Refund Purchase"
msgstr "Zwróć zakup"
#: home/templates/home/course_page.html:33
msgid "Modules"
msgstr "Moduły"
#: home/templates/home/course_page.html:33
#: home/templates/home/course_page.html:40
msgid "No modules yet."
msgstr "Brak modułów."
#: home/templates/home/course_page.html:39
#: home/templates/home/course_page.html:46
msgid ""
"You need to be logged in to access this course. Please log in or sign up to "
"view the modules."
msgstr "Musisz być zalogowany, aby uzyskać dostęp do tego kursu. Zaloguj się lub zarejestruj, aby zobaczyć moduły."
msgstr ""
"Musisz być zalogowany, aby uzyskać dostęp do tego kursu. Zaloguj się lub "
"zarejestruj, aby zobaczyć moduły."
#: home/templates/home/course_page.html:40
#: home/templates/home/course_page.html:47
#: home/templates/home/event_page.html:40
msgid "Login"
msgstr "Zaloguj się"
#: home/templates/home/course_page.html:41
#: home/templates/home/course_page.html:48
#: home/templates/home/event_page.html:43
msgid "Sign Up"
msgstr "Zarejestruj się"
#: home/templates/home/course_page.html:46
#: home/templates/home/course_page.html:53
msgid ""
"You don't have access to this course. Please purchase it to view the modules."
msgstr "Nie masz dostępu do tego kursu. Zakup go, aby zobaczyć moduły."
#: home/templates/home/course_page.html:47
#: home/templates/home/course_page.html:54
msgid "Purchase Course"
msgstr "Kup kurs"
#: home/templates/home/event_page.html:25
msgid ""
"This event has already ended. Please check our calendar for upcoming events."
msgstr "To wydarzenie już się zakończyło. Sprawdź nasz kalendarz, aby zobaczyć nadchodzące wydarzenia."
msgstr ""
"To wydarzenie już się zakończyło. Sprawdź nasz kalendarz, aby zobaczyć "
"nadchodzące wydarzenia."
#: home/templates/home/event_page.html:29
msgid "You are signed up for this event. We look forward to seeing you there!"
@@ -70,17 +161,23 @@ msgstr "Zrezygnuj"
msgid ""
"You need to be logged in to sign up for this event. Please log in or sign up "
"to reserve your spot."
msgstr "Musisz być zalogowany, aby zapisać się na to wydarzenie. Zaloguj się lub zarejestruj, aby zarezerwować swoje miejsce."
msgstr ""
"Musisz być zalogowany, aby zapisać się na to wydarzenie. Zaloguj się lub "
"zarejestruj, aby zarezerwować swoje miejsce."
#: home/templates/home/event_page.html:48
msgid ""
"This event is fully booked. Please check back later for any cancellations."
msgstr "To wydarzenie jest zarezerwowane w pełni. Sprawdź później w przypadku rezygnacji innych uczestników."
msgstr ""
"To wydarzenie jest zarezerwowane w pełni. Sprawdź później w przypadku "
"rezygnacji innych uczestników."
#: home/templates/home/event_page.html:52
msgid ""
"You are not signed up for this event. Please sign up to reserve your spot."
msgstr "Nie jesteś zapisany na to wydarzenie. Zapisz się, aby zarezerwować swoje miejsce!"
msgstr ""
"Nie jesteś zapisany na to wydarzenie. Zapisz się, aby zarezerwować swoje "
"miejsce!"
#: home/templates/home/event_page.html:54
msgid "Sign Up for Event"

View File

@@ -0,0 +1,27 @@
# Generated by Django 6.0.3 on 2026-03-17 11:34
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0015_eventpage_end_eventpage_recurrence_endless_and_more'),
('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'),
]
operations = [
migrations.CreateModel(
name='ModuleLessonPage',
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')),
('body', wagtail.fields.RichTextField(blank=True)),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 6.0.3 on 2026-03-17 14:28
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0016_modulelessonpage'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ChatMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(limit_choices_to={'is_staff': False}, on_delete=django.db.models.deletion.CASCADE, related_name='support_chats', to=settings.AUTH_USER_MODEL)),
],
),
]

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

@@ -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',),
),
]

24
home/models/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
from .pages import (
EmptyPage,
HomePage,
CoursePage,
CourseModulePage,
ModuleLessonPage,
EventPage,
)
from .event_occurrence import EventOccurrence
from .chat_message import ChatMessage
__all__ = [
"HomePage",
"EmptyPage",
"CoursePage",
"CourseModulePage",
"ModuleLessonPage",
"EventPage",
"EventOccurrence",
"ChatMessage",
]

View File

@@ -0,0 +1,26 @@
from django.contrib.auth.models import User
from django.db import models
class ChatMessage(models.Model):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="support_chats",
limit_choices_to={"is_staff": False},
) # The requester (non-admin)
sender = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="sent_messages"
) # The sender (user or admin)
content = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
@classmethod
def get_support_chat(cls, user):
return cls.objects.filter(user=user).order_by("timestamp")
@classmethod
def get_all_user_senders(cls):
user_ids = cls.objects.values_list("user", flat=True).distinct()
return User.objects.filter(id__in=user_ids, is_staff=False)

View File

@@ -0,0 +1,38 @@
from django.contrib.auth.models import User
from django.db import models
from django.utils import timezone
from modelcluster.fields import ParentalKey
from .pages import EventPage
class EventOccurrence(models.Model):
event = ParentalKey(EventPage, related_name="occurrences", on_delete=models.CASCADE)
start = models.DateTimeField()
end = models.DateTimeField()
signed_up_users = models.ManyToManyField(
User,
related_name="event_occurrences_signed_up",
blank=True,
help_text="Users who have signed up for this occurrence.",
)
class Meta:
ordering = ["start"]
def __str__(self):
return f"{self.event.title} ({self.start} - {self.end})"
@property
def attendees_count(self):
return self.signed_up_users.count()
@property
def is_past(self):
return self.end < timezone.now()
def user_signed_up(self, user):
if not user.is_authenticated:
return False
return self.signed_up_users.filter(id=user.id).exists()

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group, User
from django.db import models
from django.forms import CheckboxSelectMultiple
@@ -8,13 +8,17 @@ 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, 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
from wagtail_color_panel.fields import ColorField
from purchase.models import CoursePurchase
class EmptyPage(Page):
pass
@@ -26,8 +30,58 @@ class HomePage(Page):
content_panels = Page.content_panels + ["body"]
class BlogIndexPage(Page):
subpage_types = ["home.BlogPage"]
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 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):
body = RichTextField(blank=True)
course_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
@@ -35,28 +89,85 @@ class CoursePage(Page):
on_delete=models.SET_NULL,
related_name="+",
)
description = models.CharField(max_length=255, blank=True)
body = RichTextField(blank=True)
allowed_groups = ParentalManyToManyField(
Group,
related_name="course_pages",
help_text="Select a group to restrict access to this course. Non-members will be prompted to purchase the course to view modules.",
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
user_group_ids = user.groups.values_list("id", flat=True)
return self.allowed_groups.filter(id__in=user_group_ids).exists() # pyright: ignore[reportAttributeAccessIssue]
if self.allowed_groups.filter(id__in=user_group_ids).exists():
return True
return CoursePurchase.objects.filter(
user=user, course=self, refunded=False
).exists()
def _user_purchase_id(self, user):
if not user.is_authenticated:
return None
purchase = CoursePurchase.objects.filter(
user=user, course=self, refunded=False
).first()
print(f"User {user} purchase for course {self}: {purchase}")
return purchase.id if purchase else None
def mock_purchase(self, user):
"""Mock method to simulate purchasing this course for a user."""
if not user.is_authenticated:
return False
obj, created = CoursePurchase.objects.get_or_create(
user=user, course=self, refunded=False
)
# Add user to dedicated access group for this course
group_name = f"course_{self.id}_access"
group, _ = Group.objects.get_or_create(name=group_name)
user.groups.add(group)
# Ensure allowed_groups only includes this access group
if not self.allowed_groups.filter(id=group.id).exists():
self.allowed_groups.add(group)
return created
def save(self, *args, **kwargs):
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)
def get_context(self, request):
context = super().get_context(request)
context["user_has_access"] = self._user_has_access(request.user)
context["user_purchase_id"] = self._user_purchase_id(request.user)
return context
content_panels = Page.content_panels + [
FieldPanel("course_image"),
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"]
class CourseModulePage(Page):
@@ -78,6 +189,47 @@ class CourseModulePage(Page):
return self.title
content_panels = Page.content_panels + ["body"]
subpage_types = ["home.ModuleLessonPage"]
parent_page_types = ["home.CoursePage"]
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):
if hasattr(self, "get_parent"):
parent = self.get_parent()
if parent and hasattr(parent, "specific"):
return parent.specific
return None
@property
def full_title(self):
module = self.module
if module:
return f"{module.full_title} - {self.title}"
return self.title
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"]
class EventPageTag(TaggedItemBase):
@@ -167,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
@@ -216,6 +370,7 @@ class EventPage(Page):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.live:
self.generate_occurrences()
content_panels = Page.content_panels + [
@@ -235,36 +390,3 @@ class EventPage(Page):
FieldPanel("recurrence_repeat_until"),
FieldPanel("recurrence_endless"),
]
class EventOccurrence(models.Model):
event = ParentalKey(EventPage, related_name="occurrences", on_delete=models.CASCADE)
start = models.DateTimeField()
end = models.DateTimeField()
signed_up_users = models.ManyToManyField(
User,
related_name="event_occurrences_signed_up",
blank=True,
help_text="Users who have signed up for this occurrence.",
)
class Meta:
ordering = ["start"]
def __str__(self):
return f"{self.event.title} ({self.start} - {self.end})"
@property
def attendees_count(self):
return self.signed_up_users.count()
@property
def is_past(self):
from django.utils import timezone
return self.end < timezone.now()
def user_signed_up(self, user):
if not user.is_authenticated:
return False
return self.signed_up_users.filter(id=user.id).exists()

141
home/signals.py Normal file
View File

@@ -0,0 +1,141 @@
import logging as lg
import os
import requests
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from home.models.pages import CoursePage
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()

View File

@@ -0,0 +1,29 @@
{% extends "wagtailadmin/base.html" %}
{% load static i18n %}
{% block titletag %}
{% trans "Chat with" %} {{ chat_user.email }}
{% endblock titletag %}
{% block content %}
{% include "wagtailadmin/shared/header.html" with title="Chat" icon="mail" %}
<h1>{% trans "Admin Chat View" %}</h1>
<p>{% trans "This is the admin view of the chat. Here you can manage conversations and monitor user interactions." %}</p>
<ul>
{% for message in chat_messages %}
<li>
<strong>{{ message.sender.email }}:</strong> {{ message.content }}
</li>
{% empty %}
<li>{% trans "No messages found." %}</li>
{% endfor %}
</ul>
<form action="/chat/send/{{ chat_user.id }}/" method="post">
{% csrf_token %}
<input type="text" name="content" placeholder="{% trans "Type your message here..." %}" required>
<button type="submit">{% trans "Send" %}</button>
</form>
{% endblock content %}

View File

@@ -0,0 +1,23 @@
{% extends "wagtailadmin/base.html" %}
{% load static i18n %}
{% block titletag %}
{% trans "Chat" %}
{% endblock titletag %}
{% block content %}
{% include "wagtailadmin/shared/header.html" with title="Chat" icon="mail" %}
<h1>{% trans "Admin Chat Dashboard" %}</h1>
<ul>
{% for user in chats %}
<li>
<a href="{% url 'admin_chat' user.id %}">
{{ user.email }}
</a>
</li>
{% empty %}
<li>{% trans "No active chats found." %}</li>
{% endfor %}
</ul>
{% endblock content %}

View File

@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}
{% trans "Chat" %}
{% endblock title %}
{% block content %}
<h1>{% trans "Chat with Support" %}</h1>
<p>{% trans "This is the user chat interface. Here you can communicate with our support team for assistance." %}</p>
<ul>
{% for message in chat_messages %}
<li>
<strong>{{ message.sender.email }}:</strong> {{ message.content }}
</li>
{% empty %}
<li>{% trans "No messages found." %}</li>
{% endfor %}
</ul>
<form action="/chat/send/{{ user.id }}/" method="post">
{% csrf_token %}
<textarea name="content" placeholder="{% trans "Type your message here..." %}"></textarea>
<button type="submit">{% trans "Send" %}</button>
</form>
{% endblock content %}

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

@@ -1,6 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load wagtailcore_tags %}
{% load static i18n wagtailcore_tags %}
{% block title %}{{ page.full_title }}{% endblock %}
@@ -14,8 +13,21 @@
{% 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> &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>
{{ page.body|richtext }}
<h3 class="not-prose text-lg mt-8 mb-4 text-gray-700 font-semibold">{% trans "Lessons" %}</h3>
<ul class="list-disc list-inside">
{% for lesson in page.get_children.specific.live %}
<li>
<a href="{{ lesson.url }}" class="text-blue-600 hover:underline">{{ lesson.title }}</a>
</li>
{% empty %}
<li>{% trans "No lessons yet." %}</li>
{% endfor %}
</ul>
{% endblock content %}

View File

@@ -12,17 +12,25 @@
{% 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>
&raquo; {{ page.title }}
</h1>
{% 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 %}
<p class="not-prose text-gray-600 mb-6 text-lg">
{{ page.description }}
</p>
{{ page.body|richtext }}
{% if user_has_access %}
{% if user_purchase_id %}
<a href="{% url 'mock_refund_purchase' purchase_id=user_purchase_id %}" class="mt-4 inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">{% trans "Refund Purchase" %}</a>
{% endif %}
<h2 class="not-prose text-2xl mt-8 mb-4 text-gray-700 font-semibold">{% trans "Modules" %}</h2>
<ul class="list-disc list-inside">
{% for module in page.get_children.specific.live %}
@@ -44,7 +52,7 @@
{% else %}
<div class="not-prose mt-8 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700">
<p>{% trans "You don't have access to this course. Please purchase it to view the modules." %}</p>
<a href="" class="mt-4 inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">{% trans "Purchase Course" %}</a>
<a href="{% url 'mock_purchase_course' course_id=page.id %}" class="mt-4 inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">{% trans "Purchase Course" %}</a>
</div>
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load static wagtailcore_tags i18n %}
{% block title %}{{ page.full_title }}{% endblock %}
{% block body_class %}template-modulelessonpage{% endblock %}
{% block extra_css %}
{% endblock extra_css %}
{% block content_class %}prose{% endblock content_class %}
{% block content %}
<h2 class="not-prose text-xl mb-4 text-gray-700">
<a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; <a href="{{ page.module.course.url }}" class="font-bold hover:underline">{{ page.module.course.title }}</a>
&raquo; <a href="{{ page.module.url }}" class="font-bold hover:underline">{{ page.module.title }}</a>
&raquo; <span class="text-gray-500">{{ page.title }}</span>
</h2>
{{ page.body|richtext }}
{% endblock content %}

View File

@@ -1,52 +0,0 @@
{% load i18n wagtailcore_tags %}
<header class="header">
<div class="logo">
<a href="https://wagtail.org/">
<svg class="figure-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342.5 126.2"><title>{% trans "Visit the Wagtail website" %}</title><path fill="#FFF" d="M84 1.9v5.7s-10.2-3.8-16.8 3.1c-4.8 5-5.2 10.6-3 18.1 21.6 0 25 12.1 25 12.1L87 27l6.8-8.3c0-9.8-8.1-16.3-9.8-16.8z"/><circle cx="85.9" cy="15.9" r="2.6"/><path d="M89.2 40.9s-3.3-16.6-24.9-12.1c-2.2-7.5-1.8-13 3-18.1C73.8 3.8 84 7.6 84 7.6V1.9C80.4.3 77 0 73.2 0 59.3 0 51.6 10.4 48.3 17.4L9.2 89.3l11-2.1-20.2 39 14.1-2.5L24.9 93c30.6 0 69.8-11 64.3-52.1z"/><path d="M102.4 27l-8.6-8.3L87 27z"/><path fill="#FFF" d="M30 84.1s1-.2 2.8-.6c1.8-.4 4.3-1 7.3-1.8 1.5-.4 3.1-.9 4.8-1.5 1.7-.6 3.5-1.2 5.2-2 1.8-.7 3.6-1.6 5.4-2.6 1.8-1 3.5-2.1 5.1-3.4.4-.3.8-.6 1.2-1l1.2-1c.7-.7 1.5-1.4 2.2-2.2.7-.7 1.3-1.5 1.9-2.3l.9-1.2.4-.6.4-.6c.2-.4.5-.8.7-1.2.2-.4.4-.8.7-1.2l.3-.6.3-.6c.2-.4.4-.8.5-1.2l.9-2.4c.2-.8.5-1.6.7-2.3.2-.7.3-1.5.5-2.1.1-.7.2-1.3.3-2 .1-.6.2-1.2.2-1.7.1-.5.1-1 .2-1.5.1-1.8.1-2.8.1-2.8l1.6.1s-.1 1.1-.2 2.9c-.1.5-.1 1-.2 1.5-.1.6-.1 1.2-.3 1.8-.1.6-.3 1.3-.4 2-.2.7-.4 1.4-.6 2.2-.2.8-.5 1.5-.8 2.4-.3.8-.6 1.6-1 2.5l-.6 1.2-.3.6-.3.6c-.2.4-.5.8-.7 1.3-.3.4-.5.8-.8 1.2-.1.2-.3.4-.4.6l-.4.6-.9 1.2c-.7.8-1.3 1.6-2.1 2.3-.7.8-1.5 1.4-2.3 2.2l-1.2 1c-.4.3-.8.6-1.3.9-1.7 1.2-3.5 2.3-5.3 3.3-1.8.9-3.7 1.8-5.5 2.5-1.8.7-3.6 1.3-5.3 1.8-1.7.5-3.3 1-4.9 1.3-3 .7-5.6 1.3-7.4 1.6-1.6.6-2.6.8-2.6.8z"/><g fill="#231F20"><path d="M127 83.9h-8.8l-12.6-36.4h7.9l9 27.5 9-27.5h7.9l9 27.5 9-27.5h7.9L153 83.9h-8.8L135.6 59 127 83.9zM200.1 83.9h-7V79c-3 3.6-7 5.4-12.1 5.4-3.8 0-6.9-1.1-9.4-3.2s-3.7-5-3.7-8.6c0-3.6 1.3-6.3 4-8 2.6-1.8 6.2-2.7 10.7-2.7h9.9v-1.4c0-4.8-2.7-7.3-8.1-7.3-3.4 0-6.9 1.2-10.5 3.7l-3.4-4.8c4.4-3.5 9.4-5.3 15.1-5.3 4.3 0 7.8 1.1 10.5 3.2 2.7 2.2 4.1 5.6 4.1 10.2v23.7zm-7.7-13.6v-3.1h-8.6c-5.5 0-8.3 1.7-8.3 5.2 0 1.8.7 3.1 2.1 4.1 1.4.9 3.3 1.4 5.7 1.4 2.4 0 4.6-.7 6.4-2.1 1.8-1.3 2.7-3.1 2.7-5.5zM241.7 47.5v31.7c0 6.4-1.7 11.3-5.2 14.5-3.5 3.2-8 4.8-13.4 4.8-5.5 0-10.4-1.7-14.8-5.1l3.6-5.8c3.6 2.7 7.1 4 10.8 4 3.6 0 6.5-.9 8.6-2.8 2.1-1.9 3.2-4.9 3.2-9v-4.7c-1.1 2.1-2.8 3.9-4.9 5.1-2.1 1.3-4.5 1.9-7.1 1.9-4.8 0-8.8-1.7-11.9-5.1-3.1-3.4-4.7-7.6-4.7-12.6s1.6-9.2 4.7-12.6c3.1-3.4 7.1-5.1 11.9-5.1 4.8 0 8.7 2 11.7 6v-5.4h7.5zm-28.4 16.8c0 3 .9 5.6 2.8 7.7 1.8 2.2 4.3 3.2 7.5 3.2 3.1 0 5.7-1 7.6-3.1 1.9-2.1 2.9-4.7 2.9-7.8 0-3.1-1-5.8-2.9-7.9-2-2.2-4.5-3.2-7.6-3.2-3.1 0-5.6 1.1-7.4 3.4-2 2.1-2.9 4.7-2.9 7.7zM260.9 53.6v18.5c0 1.7.5 3.1 1.4 4.1.9 1 2.2 1.5 3.8 1.5 1.6 0 3.2-.8 4.7-2.4l3.1 5.4c-2.7 2.4-5.7 3.6-8.9 3.6-3.3 0-6-1.1-8.3-3.4-2.3-2.3-3.5-5.3-3.5-9.1V53.6h-4.6v-6.2h4.6V36.1h7.7v11.4h9.6v6.2h-9.6zM309.5 83.9h-7V79c-3 3.6-7 5.4-12.1 5.4-3.8 0-6.9-1.1-9.4-3.2s-3.7-5-3.7-8.6c0-3.6 1.3-6.3 4-8 2.6-1.8 6.2-2.7 10.7-2.7h9.9v-1.4c0-4.8-2.7-7.3-8.1-7.3-3.4 0-6.9 1.2-10.5 3.7l-3.4-4.8c4.4-3.5 9.4-5.3 15.1-5.3 4.3 0 7.8 1.1 10.5 3.2 2.7 2.2 4.1 5.6 4.1 10.2v23.7zm-7.7-13.6v-3.1h-8.6c-5.5 0-8.3 1.7-8.3 5.2 0 1.8.7 3.1 2.1 4.1 1.4.9 3.3 1.4 5.7 1.4 2.4 0 4.6-.7 6.4-2.1 1.8-1.3 2.7-3.1 2.7-5.5zM319.3 40.2c-1-1-1.4-2.1-1.4-3.4 0-1.3.5-2.5 1.4-3.4 1-1 2.1-1.4 3.4-1.4 1.3 0 2.5.5 3.4 1.4 1 1 1.4 2.1 1.4 3.4 0 1.3-.5 2.5-1.4 3.4s-2.1 1.4-3.4 1.4c-1.3.1-2.4-.4-3.4-1.4zm7.2 43.7h-7.7V47.5h7.7v36.4zM342.5 83.9h-7.7V33.1h7.7v50.8z"/></g></svg>
</a>
</div>
<div class="header-link">
{% comment %}
This works for all cases but prerelease versions:
{% endcomment %}
<a href="{% wagtail_documentation_path %}/releases/{% wagtail_release_notes_path %}">
{% trans "View the release notes" %}
</a>
</div>
</header>
<main class="main">
<div class="figure">
<svg class="figure-space" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" aria-hidden="true">
<path class="egg" fill="currentColor" d="M150 250c-42.741 0-75-32.693-75-90s42.913-110 75-110c32.088 0 75 52.693 75 110s-32.258 90-75 90z"/>
<ellipse fill="#ddd" cx="150" cy="270" rx="40" ry="7"/>
</svg>
</div>
<div class="main-text">
<h1>{% trans "Welcome to your new Wagtail site!" %}</h1>
<p>{% trans 'Please feel free to <a href="https://github.com/wagtail/wagtail/wiki/Slack">join our community on Slack</a>, or get started with one of the links below.' %}</p>
</div>
</main>
<footer class="footer" role="contentinfo">
<a class="option option-one" href="{% wagtail_documentation_path %}/">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 21c0 .5.4 1 1 1h4c.6 0 1-.5 1-1v-1H9v1zm3-19C8.1 2 5 5.1 5 9c0 2.4 1.2 4.5 3 5.7V17c0 .5.4 1 1 1h6c.6 0 1-.5 1-1v-2.3c1.8-1.3 3-3.4 3-5.7 0-3.9-3.1-7-7-7zm2.9 11.1l-.9.6V16h-4v-2.3l-.9-.6C7.8 12.2 7 10.6 7 9c0-2.8 2.2-5 5-5s5 2.2 5 5c0 1.6-.8 3.2-2.1 4.1z"/></svg>
<div>
<h2>{% trans "Wagtail Documentation" %}</h2>
<p>{% trans "Topics, references, & how-tos" %}</p>
</div>
</a>
<a class="option option-two" href="{% wagtail_documentation_path %}/getting_started/tutorial.html">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
<div>
<h2>{% trans "Tutorial" %}</h2>
<p>{% trans "Build your first Wagtail site" %}</p>
</div>
</a>
<a class="option option-three" href="{% url 'wagtailadmin_home' %}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.5 13c-1.2 0-3.07.34-4.5 1-1.43-.67-3.3-1-4.5-1C5.33 13 1 14.08 1 16.25V19h22v-2.75c0-2.17-4.33-3.25-6.5-3.25zm-4 4.5h-10v-1.25c0-.54 2.56-1.75 5-1.75s5 1.21 5 1.75v1.25zm9 0H14v-1.25c0-.46-.2-.86-.52-1.22.88-.3 1.96-.53 3.02-.53 2.44 0 5 1.21 5 1.75v1.25zM7.5 12c1.93 0 3.5-1.57 3.5-3.5S9.43 5 7.5 5 4 6.57 4 8.5 5.57 12 7.5 12zm0-5.5c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm9 5.5c1.93 0 3.5-1.57 3.5-3.5S18.43 5 16.5 5 13 6.57 13 8.5s1.57 3.5 3.5 3.5zm0-5.5c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2z"/></svg>
<div>
<h2>{% trans "Admin Interface" %}</h2>
<p>{% trans "Create your superuser first!" %}</p>
</div>
</a>
</footer>

7
home/urls.py Normal file
View File

@@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("chat/", views.user_chat, name="user_chat"),
path("chat/send/<int:user_id>/", views.user_chat_send, name="user_chat_send"),
]

44
home/views.py Normal file
View File

@@ -0,0 +1,44 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.shortcuts import redirect, render
from home.models import ChatMessage
@login_required
def admin_chat_dashboard(request):
chats = ChatMessage.get_all_user_senders()
return render(request, "chat/admin/admin_chat_dashboard.html", {"chats": chats})
@login_required
def admin_chat(request, user_id):
chat_user = User.objects.filter(id=user_id, is_staff=False).first()
print(chat_user)
chat_messages = ChatMessage.get_support_chat(chat_user)
return render(
request,
"chat/admin/admin_chat.html",
{"chat_user": chat_user, "chat_messages": chat_messages},
)
@login_required
def user_chat(request):
if request.user.is_staff:
return redirect("admin_chat_dashboard")
chat_messages = ChatMessage.get_support_chat(request.user)
return render(request, "chat/user_chat.html", {"chat_messages": chat_messages})
@login_required
def user_chat_send(request, user_id):
if request.method == "POST":
content = request.POST.get("content")
content = content.strip() if content else ""
if content:
user = User.objects.filter(id=user_id).first()
ChatMessage.objects.create(user=user, sender=request.user, content=content)
if request.user.is_staff:
return redirect("admin_chat", user_id=user_id)
return redirect("user_chat")

View File

@@ -1,7 +1,12 @@
from wagtail.admin.menu import MenuItem
import wagtail.admin.rich_text.editors.draftail.features as draftail_features
from wagtail.admin.rich_text.converters.html_to_contentstate import BlockElementHandler
from wagtail import hooks
from django.urls import path, reverse
from . import views
@hooks.register("register_rich_text_features")
def register_code_block_feature(features):
@@ -39,3 +44,20 @@ def register_code_block_feature(features):
# Optional: add to default features
features.default_features.append(feature_name)
@hooks.register("register_admin_urls")
def register_admin_chat_dashboard_url():
return [
path("chat/", views.admin_chat_dashboard, name="admin_chat_dashboard"),
path(
"chat/user/<int:user_id>/",
views.admin_chat,
name="admin_chat",
),
]
@hooks.register("register_admin_menu_item")
def register_admin_chat_menu_item():
return MenuItem("Chat", reverse("admin_chat_dashboard"), icon_name="mail")

View File

@@ -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)

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-16 12:38+0000\n"
"POT-Creation-Date: 2026-03-20 12:18+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -26,7 +26,7 @@ msgstr ""
msgid "Sorry, you don't have permission to access this page."
msgstr ""
#: kursy/templates/calendar.html:4 kursy/templates/header.html:8
#: kursy/templates/calendar.html:4
msgid "Course Calendar"
msgstr ""
@@ -34,22 +34,63 @@ msgstr ""
msgid "Loading..."
msgstr ""
#: kursy/templates/header.html:10
msgid "Logout"
#: kursy/templates/header.html:7
msgid "Courses"
msgstr ""
#: kursy/templates/header.html:12
msgid "Login"
#: kursy/templates/header.html:8
msgid "Calendar"
msgstr ""
#: kursy/templates/header.html:13
msgid "Logout"
msgstr ""
#: kursy/templates/header.html:15 kursy/templates/occurrence_detail.html:39
msgid "Login"
msgstr ""
#: kursy/templates/header.html:16 kursy/templates/occurrence_detail.html:42
msgid "Sign Up"
msgstr ""
#: kursy/templates/header.html:32
#: kursy/templates/header.html:35
msgid "Search courses..."
msgstr ""
#: kursy/templates/occurrence_detail.html:24
msgid ""
"This event has already ended. Please check our calendar for upcoming events."
msgstr ""
#: kursy/templates/occurrence_detail.html:28
msgid "You are signed up for this event. We look forward to seeing you there!"
msgstr ""
#: kursy/templates/occurrence_detail.html:31
msgid "Cancel Sign Up"
msgstr ""
#: kursy/templates/occurrence_detail.html:37
msgid ""
"You need to be logged in to sign up for this event. Please log in or sign up "
"to reserve your spot."
msgstr ""
#: kursy/templates/occurrence_detail.html:47
msgid ""
"This event is fully booked. Please check back later for any cancellations."
msgstr ""
#: kursy/templates/occurrence_detail.html:51
msgid ""
"You are not signed up for this event. Please sign up to reserve your spot."
msgstr ""
#: kursy/templates/occurrence_detail.html:53
msgid "Sign Up for Event"
msgstr ""
#: kursy/templates/profile.html:6
msgid "Hello, "
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-16 12:38+0000\n"
"POT-Creation-Date: 2026-03-20 12:18+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -28,7 +28,7 @@ msgstr "Odmowa dostępu"
msgid "Sorry, you don't have permission to access this page."
msgstr "Przepraszamy, ale nie masz uprawnień do dostępu do tej strony."
#: kursy/templates/calendar.html:4 kursy/templates/header.html:8
#: kursy/templates/calendar.html:4
msgid "Course Calendar"
msgstr "Kalendarz kursów"
@@ -36,22 +36,69 @@ msgstr "Kalendarz kursów"
msgid "Loading..."
msgstr "Ładowanie..."
#: kursy/templates/header.html:10
#: kursy/templates/header.html:7
msgid "Courses"
msgstr "Kursy"
#: kursy/templates/header.html:8
msgid "Calendar"
msgstr "Kalendarz"
#: kursy/templates/header.html:13
msgid "Logout"
msgstr "Wyloguj się"
#: kursy/templates/header.html:12
#: kursy/templates/header.html:15 kursy/templates/occurrence_detail.html:39
msgid "Login"
msgstr "Zaloguj się"
#: kursy/templates/header.html:13
#: kursy/templates/header.html:16 kursy/templates/occurrence_detail.html:42
msgid "Sign Up"
msgstr "Zarejestruj się"
#: kursy/templates/header.html:32
#: kursy/templates/header.html:35
msgid "Search courses..."
msgstr "Szukaj kursów..."
#: kursy/templates/occurrence_detail.html:24
msgid ""
"This event has already ended. Please check our calendar for upcoming events."
msgstr ""
#: kursy/templates/occurrence_detail.html:28
msgid "You are signed up for this event. We look forward to seeing you there!"
msgstr ""
#: kursy/templates/occurrence_detail.html:31
msgid "Cancel Sign Up"
msgstr "Zrezygnuj"
#: kursy/templates/occurrence_detail.html:37
msgid ""
"You need to be logged in to sign up for this event. Please log in or sign up "
"to reserve your spot."
msgstr ""
"Musisz być zalogowany, aby zapisać się na to wydarzenie. Zaloguj się lub "
"zarejestruj, aby zarezerwować swoje miejsce."
#: kursy/templates/occurrence_detail.html:47
msgid ""
"This event is fully booked. Please check back later for any cancellations."
msgstr ""
"To wydarzenie jest w pełni zarezerwowane. Sprawdź ponownie później w "
"przypadku zwolnienia miejsc."
#: kursy/templates/occurrence_detail.html:51
msgid ""
"You are not signed up for this event. Please sign up to reserve your spot."
msgstr ""
"Nie jesteś zapisany na to wydarzenie. Zapisz się, aby zarezerwować swoje "
"miejsce."
#: kursy/templates/occurrence_detail.html:53
msgid "Sign Up for Event"
msgstr "Zapisz się"
#: kursy/templates/profile.html:6
msgid "Hello, "
msgstr "Witaj, "

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.id}",
"email": request.user.email,
}

View File

@@ -32,6 +32,7 @@ dotenv.load_dotenv(BASE_DIR / ".env")
INSTALLED_APPS = [
"home",
"search",
"purchase",
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.embeds",
@@ -58,6 +59,7 @@ INSTALLED_APPS = [
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.github",
"oauth2_provider",
"tailwind",
"theme",
"widget_tweaks",
@@ -75,6 +77,7 @@ MIDDLEWARE = [
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
"allauth.account.middleware.AccountMiddleware",
"django.middleware.locale.LocaleMiddleware",
"oauth2_provider.middleware.OAuth2TokenMiddleware",
]
ROOT_URLCONF = "kursy.urls"
@@ -131,6 +134,26 @@ SOCIALACCOUNT_PROVIDERS = {
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
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
@@ -218,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"
@@ -252,3 +312,6 @@ WAGTAILDOCS_EXTENSIONS = [
]
TAILWIND_APP_NAME = "theme"
# Gitea API
GITEA_URL = "http://localhost:3000/api/v1"

View File

@@ -1,11 +1,14 @@
{% load i18n wagtailcore_tags %}
<header class="bg-blue-900 text-white shadow-md relative">
<header class="bg-blue-900 text-white shadow-md lg:sticky top-0 z-40">
<div class="container mx-auto flex items-center justify-between py-4 px-6">
{% wagtail_site as current_site %}
<nav 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 "Calendar" %}</a>
</nav>
<nav class="flex items-center gap-4">
<a href="{% url 'calendar' %}" class="hover:underline">{% trans "Course Calendar" %}</a>
{% if user.is_authenticated %}
<a href="{% url 'account_logout' %}" class="hover:underline">{% trans "Logout" %}</a>
{% else %}
@@ -28,8 +31,8 @@
</div>
<div class="container mx-auto px-6 mb-2 md:mb-0">
<form action="{% url 'search' %}" method="get" class="flex items-center bg-blue-950 rounded-md md:w-auto md:absolute md:left-1/2 md:top-1/2 md:transform md:-translate-x-1/2 md:-translate-y-1/2 md:mt-0">
<input type="text" name="query" placeholder="{% trans 'Search courses...' %}" class="rounded-md px-3 py-2 w-full md:w-auto focus:outline-none">
<form action="{% url 'search' %}" method="get" class="flex items-center bg-blue-950 rounded-md mb-2 lg:w-auto lg:absolute lg:left-1/2 lg:top-1/2 lg:transform lg:-translate-x-1/2 lg:-translate-y-1/2 lg:mt-0">
<input type="text" name="query" placeholder="{% trans 'Search courses...' %}" class="rounded-lg px-3 py-2 w-full lg:w-auto focus:outline-none">
<button type="submit" class="bg-white text-blue-900 rounded-md px-3 py-2 hover:bg-gray-200 transition"><i class="fi fi-br-search"></i></button>
</form>
</div>

View File

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

0
purchase/__init__.py Normal file
View File

3
purchase/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
purchase/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class PurchaseConfig(AppConfig):
name = 'purchase'

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0.3 on 2026-03-19 17:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('home', '0019_coursepage_description'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CoursePurchase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('purchased_at', models.DateTimeField(auto_now_add=True)),
('refunded', models.BooleanField(default=False)),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='home.coursepage')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

23
purchase/models.py Normal file
View File

@@ -0,0 +1,23 @@
from django.conf import settings
from django.contrib.auth.models import Group
from django.db import models
class CoursePurchase(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
course = models.ForeignKey("home.CoursePage", on_delete=models.CASCADE)
purchased_at = models.DateTimeField(auto_now_add=True)
refunded = models.BooleanField(default=False)
def mock_refund(self):
self.refunded = True
self.save()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
group_name = f"course_{self.course.id}_access"
group, _ = Group.objects.get_or_create(name=group_name)
if self.refunded:
print(f"Removing user {self.user} from group {group_name} due to refund")
self.user.groups.remove(group)

3
purchase/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

16
purchase/urls.py Normal file
View File

@@ -0,0 +1,16 @@
from django.urls import path
from . import views
urlpatterns = [
path(
"mock-purchase/<int:course_id>/",
views.mock_purchase_course,
name="mock_purchase_course",
),
path(
"mock-refund/<int:purchase_id>/",
views.mock_refund_purchase,
name="mock_refund_purchase",
),
]

21
purchase/views.py Normal file
View File

@@ -0,0 +1,21 @@
from django.shortcuts import redirect, render
from django.urls import reverse
from home.models import CoursePage
from purchase.models import CoursePurchase
def mock_purchase_course(request, course_id):
course = CoursePage.objects.get(id=course_id)
course.mock_purchase(request.user)
return redirect(course.url)
def mock_refund_purchase(request, purchase_id):
purchase = CoursePurchase.objects.get(id=purchase_id)
purchase.mock_refund()
return redirect(purchase.course.url)

View File

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

View File

@@ -12,7 +12,6 @@
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/typography": "^0.5.19",
"cross-env": "^10.1.0",
"daisyui": "^5.3.10",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"postcss-nested": "^7.0.2",
@@ -596,16 +595,6 @@
"node": ">=4"
}
},
"node_modules/daisyui": {
"version": "5.5.19",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz",
"integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/dependency-graph": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz",

View File

@@ -16,7 +16,6 @@
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/typography": "^0.5.19",
"cross-env": "^10.1.0",
"daisyui": "^5.3.10",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"postcss-nested": "^7.0.2",

View File

@@ -1,8 +1,6 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "daisyui";
/**
* A catch-all path to Django template files, JavaScript, and Python files
* that contain Tailwind CSS classes and will be scanned by Tailwind to generate the final CSS file.

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" },
]
[[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]]
name = "django-permissionedforms"
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" },
]
[[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]]
name = "kursy"
version = "0.1.0"
@@ -466,6 +494,7 @@ dependencies = [
{ name = "django-allauth", extra = ["socialaccount"] },
{ name = "django-allauth-ui" },
{ name = "django-browser-reload" },
{ name = "django-oauth-toolkit" },
{ name = "django-tailwind" },
{ name = "django-widget-tweaks" },
{ name = "python-dotenv" },
@@ -482,6 +511,7 @@ requires-dist = [
{ name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.15.0" },
{ name = "django-allauth-ui", specifier = ">=1.8.1" },
{ 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-widget-tweaks", specifier = ">=1.5.1" },
{ name = "python-dotenv", specifier = ">=1.2.2" },