28 Commits

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

View File

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

View File

@@ -0,0 +1,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),
),
]

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,5 @@
from datetime import date, datetime, timedelta from datetime import datetime, timedelta
from django import forms
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.db import models from django.db import models
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
@@ -9,7 +8,7 @@ from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase from taggit.models import TaggedItemBase
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField, StreamField from wagtail.fields import RichTextField
from wagtail.models import Page from wagtail.models import Page
from wagtail.models.copying import ParentalManyToManyField from wagtail.models.copying import ParentalManyToManyField
from wagtail_color_panel.edit_handlers import NativeColorPanel from wagtail_color_panel.edit_handlers import NativeColorPanel
@@ -26,8 +25,29 @@ class HomePage(Page):
content_panels = Page.content_panels + ["body"] content_panels = Page.content_panels + ["body"]
class CourseIndexPage(Page):
subpage_types = ["home.CoursePage"]
def get_context(self, request):
context = super().get_context(request)
all_courses = self.get_children().live()
purchased_courses = set()
other_courses = set()
for course in all_courses:
if course.specific._user_has_access(request.user):
purchased_courses.add(course)
else:
other_courses.add(course)
context["purchased_courses"] = sorted(
purchased_courses, key=lambda c: c.title.lower()
)
context["other_courses"] = sorted(other_courses, key=lambda c: c.title.lower())
return context
class CoursePage(Page): class CoursePage(Page):
body = RichTextField(blank=True)
course_image = models.ForeignKey( course_image = models.ForeignKey(
"wagtailimages.Image", "wagtailimages.Image",
null=True, null=True,
@@ -35,6 +55,8 @@ class CoursePage(Page):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+", related_name="+",
) )
description = models.CharField(max_length=255, blank=True)
body = RichTextField(blank=True)
allowed_groups = ParentalManyToManyField( allowed_groups = ParentalManyToManyField(
Group, Group,
related_name="course_pages", related_name="course_pages",
@@ -54,9 +76,11 @@ class CoursePage(Page):
content_panels = Page.content_panels + [ content_panels = Page.content_panels + [
FieldPanel("course_image"), FieldPanel("course_image"),
FieldPanel("description"),
FieldPanel("body"), FieldPanel("body"),
FieldPanel("allowed_groups", widget=CheckboxSelectMultiple), FieldPanel("allowed_groups", widget=CheckboxSelectMultiple),
] ]
parent_page_types = ["home.CourseIndexPage"]
subpage_types = ["home.CourseModulePage"] subpage_types = ["home.CourseModulePage"]
@@ -260,36 +284,3 @@ class EventPage(Page):
FieldPanel("recurrence_repeat_until"), FieldPanel("recurrence_repeat_until"),
FieldPanel("recurrence_endless"), 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()

39
home/signals.py Normal file
View File

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

View File

@@ -0,0 +1,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

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

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 import wagtail.admin.rich_text.editors.draftail.features as draftail_features
from wagtail.admin.rich_text.converters.html_to_contentstate import BlockElementHandler from wagtail.admin.rich_text.converters.html_to_contentstate import BlockElementHandler
from wagtail import hooks from wagtail import hooks
from django.urls import path, reverse
from . import views
@hooks.register("register_rich_text_features") @hooks.register("register_rich_text_features")
def register_code_block_feature(features): def register_code_block_feature(features):
@@ -39,3 +44,20 @@ def register_code_block_feature(features):
# Optional: add to default features # Optional: add to default features
features.default_features.append(feature_name) 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")

11
kursy/oauth_validators.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@
"@tailwindcss/postcss": "^4.1.16", "@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"daisyui": "^5.3.10",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
"postcss-nested": "^7.0.2", "postcss-nested": "^7.0.2",
@@ -596,16 +595,6 @@
"node": ">=4" "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": { "node_modules/dependency-graph": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", "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/postcss": "^4.1.16",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"daisyui": "^5.3.10",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
"postcss-nested": "^7.0.2", "postcss-nested": "^7.0.2",

View File

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