Compare commits
69 Commits
feat/add-e
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c71dc1e47 | |||
|
dd936473d8
|
|||
|
787440d56f
|
|||
|
37b0a6a95b
|
|||
|
345914a519
|
|||
|
7e24ded8ee
|
|||
|
64edf6656e
|
|||
|
a2ad8e7ac9
|
|||
|
a0b4697c61
|
|||
|
983384f62b
|
|||
|
668ddccea5
|
|||
|
6dd826c3bd
|
|||
|
e74c1fb28d
|
|||
|
cb19bc6262
|
|||
|
a918ee73c4
|
|||
|
5913e847bc
|
|||
|
18b21b0892
|
|||
|
efb3799e12
|
|||
|
306d39bd22
|
|||
|
e503d69235
|
|||
|
57ec3162d0
|
|||
|
c4e9ec5484
|
|||
|
c8732a05cb
|
|||
|
6810e540e5
|
|||
|
21500e0f10
|
|||
|
be42d71bb8
|
|||
|
b5e9e1ec66
|
|||
|
d575c836e9
|
|||
|
84a6c4cf5e
|
|||
|
e46f034d9e
|
|||
|
dc7e34f5b6
|
|||
|
f002651e2a
|
|||
|
c789eeb4ff
|
|||
|
acb6ea58ce
|
|||
|
72fca4228c
|
|||
|
9f779407af
|
|||
|
f2f594afb6
|
|||
|
95ab896e5f
|
|||
|
4f58cb0320
|
|||
|
294ea9a28b
|
|||
|
e56aff1a5c
|
|||
|
71d4580a82
|
|||
|
0356374870
|
|||
|
ffc33d3be4
|
|||
|
730e041794
|
|||
|
e0f3f094ff
|
|||
|
157ee875e1
|
|||
|
12de01c2dc
|
|||
|
718aeb9cf5
|
|||
|
9761daf820
|
|||
|
1d0fd27e04
|
|||
|
ac31336acc
|
|||
|
82514a9418
|
|||
|
b599ac6fa3
|
|||
|
fd6209470b
|
|||
|
de48747884
|
|||
|
684871833a
|
|||
|
ef69b99068
|
|||
|
88c797e4b0
|
|||
|
a0819a6552
|
|||
|
e4add89ba8
|
|||
|
7cbec4fc9c
|
|||
|
899ed539c4
|
|||
|
c22ef183f8
|
|||
|
f95a4915bd
|
|||
|
42830dbc45
|
|||
|
fd537c82fc
|
|||
|
a2a38dbc6d
|
|||
|
a44002b714
|
0
course_calendar/__init__.py
Normal file
0
course_calendar/__init__.py
Normal file
6
course_calendar/admin.py
Normal file
6
course_calendar/admin.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
# from .forms import EventForm
|
||||
#
|
||||
# admin.site.register(EventForm)
|
||||
5
course_calendar/apps.py
Normal file
5
course_calendar/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CourseCalendarConfig(AppConfig):
|
||||
name = 'course_calendar'
|
||||
21
course_calendar/forms.py
Normal file
21
course_calendar/forms.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.forms import fields as form_fields
|
||||
from django.forms.widgets import ColorInput
|
||||
from wagtail.admin.forms import WagtailAdminModelForm
|
||||
from wagtail_color_panel.edit_handlers import NativeColorPanel
|
||||
|
||||
from course_calendar.models import CalendarEvent
|
||||
|
||||
|
||||
class EventForm(WagtailAdminModelForm):
|
||||
class Meta:
|
||||
model = CalendarEvent
|
||||
fields = "__all__"
|
||||
|
||||
widgets = {
|
||||
"color": ColorInput(),
|
||||
"start": form_fields.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
"end": form_fields.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
"recurrence_start_time": form_fields.TimeInput(attrs={"type": "time"}),
|
||||
"recurrence_end_time": form_fields.TimeInput(attrs={"type": "time"}),
|
||||
"recurrence_end_date": form_fields.DateInput(attrs={"type": "date"}),
|
||||
}
|
||||
72
course_calendar/migrations/0001_initial.py
Normal file
72
course_calendar/migrations/0001_initial.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# Generated by Django 6.0.3 on 2026-03-30 09:43
|
||||
|
||||
import django.db.models.deletion
|
||||
import modelcluster.contrib.taggit
|
||||
import wagtail.fields
|
||||
import wagtail_color_panel.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
|
||||
('wagtailimages', '0027_image_description'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CalendarEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('color', wagtail_color_panel.fields.ColorField(default='#1c398e', max_length=7)),
|
||||
('start', models.DateTimeField(blank=True, help_text='Start date and time of the singular event (ignored if recurrence is enabled)', null=True)),
|
||||
('end', models.DateTimeField(blank=True, help_text='End date and time of the singular event (ignored if recurrence is enabled)', null=True)),
|
||||
('recurrence_enabled', models.BooleanField(default=False, help_text='Enable automatic recurrence for this event')),
|
||||
('recurrence_days_of_week', models.JSONField(blank=True, default=list, help_text='Days of the week for recurrence (e.g., [0,3] for Mon & Thu)')),
|
||||
('recurrence_start_time', models.TimeField(blank=True, help_text='Start time for each occurrence', null=True)),
|
||||
('recurrence_end_time', models.TimeField(blank=True, help_text='End time for each occurrence', null=True)),
|
||||
('recurrence_repeat_until', models.DateField(blank=True, help_text='Repeat until this date (inclusive)', null=True)),
|
||||
('recurrence_endless', models.BooleanField(default=False, help_text="If enabled, recurrence will not end (ignore 'repeat until' date)")),
|
||||
('location', models.CharField(blank=True, max_length=255)),
|
||||
('max_attendees', models.PositiveIntegerField(blank=True, help_text='Maximum number of attendees. Leave blank for unlimited.', null=True)),
|
||||
('description', wagtail.fields.RichTextField(blank=True)),
|
||||
('hosts', models.ManyToManyField(help_text='Select users who will be listed as hosts of this event.', related_name='hosted_events', to=settings.AUTH_USER_MODEL)),
|
||||
('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventOccurrence',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('start', models.DateTimeField()),
|
||||
('end', models.DateTimeField()),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='occurrences', to='course_calendar.calendarevent')),
|
||||
('signed_up_users', models.ManyToManyField(blank=True, help_text='Users who have signed up for this occurrence.', related_name='event_occurrences_signed_up', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['start'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventTag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content_object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='course_calendar.calendarevent')),
|
||||
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='calendarevent',
|
||||
name='tags',
|
||||
field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='course_calendar.EventTag', to='taggit.Tag', verbose_name='Tags'),
|
||||
),
|
||||
]
|
||||
0
course_calendar/migrations/__init__.py
Normal file
0
course_calendar/migrations/__init__.py
Normal file
@@ -1,95 +1,29 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
from django.forms.widgets import CheckboxSelectMultiple
|
||||
from django.utils import timezone
|
||||
from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||
from modelcluster.fields import ParentalKey
|
||||
from taggit.models import TaggedItemBase
|
||||
from wagtail.admin.panels import FieldPanel
|
||||
from wagtail.fields import RichTextField, StreamField
|
||||
from wagtail.models import Page
|
||||
from wagtail.models.copying import ParentalManyToManyField
|
||||
from wagtail.admin.panels import FieldPanel, ObjectList
|
||||
from wagtail.fields import RichTextField
|
||||
from wagtail_color_panel.edit_handlers import NativeColorPanel
|
||||
from wagtail_color_panel.fields import ColorField
|
||||
|
||||
|
||||
class EmptyPage(Page):
|
||||
pass
|
||||
|
||||
|
||||
class HomePage(Page):
|
||||
body = RichTextField(blank=True)
|
||||
|
||||
content_panels = Page.content_panels + ["body"]
|
||||
|
||||
|
||||
class CoursePage(Page):
|
||||
body = RichTextField(blank=True)
|
||||
course_image = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
def get_context(self, request):
|
||||
context = super().get_context(request)
|
||||
context["user_has_access"] = self._user_has_access(request.user)
|
||||
return context
|
||||
|
||||
content_panels = Page.content_panels + [
|
||||
FieldPanel("course_image"),
|
||||
FieldPanel("body"),
|
||||
FieldPanel("allowed_groups", widget=CheckboxSelectMultiple),
|
||||
]
|
||||
|
||||
|
||||
class CourseModulePage(Page):
|
||||
body = RichTextField(blank=True)
|
||||
|
||||
@property
|
||||
def course(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):
|
||||
course = self.course
|
||||
if course:
|
||||
return f"{course.title} - {self.title}"
|
||||
return self.title
|
||||
|
||||
content_panels = Page.content_panels + ["body"]
|
||||
|
||||
|
||||
class EventPageTag(TaggedItemBase):
|
||||
content_object = ParentalKey(
|
||||
"home.EventPage",
|
||||
class EventTag(TaggedItemBase):
|
||||
content_object = models.ForeignKey(
|
||||
"course_calendar.CalendarEvent",
|
||||
related_name="tagged_items",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
|
||||
class EventPage(Page):
|
||||
tags = ClusterTaggableManager(through=EventPageTag, blank=True)
|
||||
class CalendarEvent(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
tags = ClusterTaggableManager(through=EventTag, blank=True)
|
||||
color = ColorField(default="#1c398e")
|
||||
image = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
@@ -151,22 +85,18 @@ class EventPage(Page):
|
||||
)
|
||||
|
||||
description = RichTextField(blank=True)
|
||||
hosts = ParentalManyToManyField(
|
||||
hosts = models.ManyToManyField(
|
||||
User,
|
||||
related_name="hosted_events",
|
||||
help_text="Select users who will be listed as hosts of this event.",
|
||||
)
|
||||
|
||||
def get_context(self, request):
|
||||
context = super().get_context(request)
|
||||
# Occurrence-specific context should be handled in views/templates
|
||||
return context
|
||||
|
||||
def generate_occurrences(self, days_ahead=30):
|
||||
"""
|
||||
Generate EventOccurrence objects for this event based on recurrence settings.
|
||||
For endless recurrence, generate up to days_ahead into the future.
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -214,31 +144,11 @@ class EventPage(Page):
|
||||
else:
|
||||
EventOccurrence.objects.create(event=self, start=start_dt, end=end_dt)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
self.generate_occurrences()
|
||||
|
||||
content_panels = Page.content_panels + [
|
||||
FieldPanel("tags"),
|
||||
NativeColorPanel("color"),
|
||||
FieldPanel("image"),
|
||||
FieldPanel("location"),
|
||||
FieldPanel("max_attendees"),
|
||||
FieldPanel("hosts", widget=CheckboxSelectMultiple),
|
||||
FieldPanel("description"),
|
||||
FieldPanel("start"),
|
||||
FieldPanel("end"),
|
||||
FieldPanel("recurrence_enabled"),
|
||||
FieldPanel("recurrence_days_of_week"),
|
||||
FieldPanel("recurrence_start_time"),
|
||||
FieldPanel("recurrence_end_time"),
|
||||
FieldPanel("recurrence_repeat_until"),
|
||||
FieldPanel("recurrence_endless"),
|
||||
]
|
||||
|
||||
|
||||
class EventOccurrence(models.Model):
|
||||
event = ParentalKey(EventPage, related_name="occurrences", on_delete=models.CASCADE)
|
||||
event = models.ForeignKey(
|
||||
CalendarEvent, related_name="occurrences", on_delete=models.CASCADE
|
||||
)
|
||||
start = models.DateTimeField()
|
||||
end = models.DateTimeField()
|
||||
signed_up_users = models.ManyToManyField(
|
||||
@@ -260,7 +170,6 @@ class EventOccurrence(models.Model):
|
||||
|
||||
@property
|
||||
def is_past(self):
|
||||
from django.utils import timezone
|
||||
|
||||
return self.end < timezone.now()
|
||||
|
||||
9
course_calendar/signals.py
Normal file
9
course_calendar/signals.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import CalendarEvent
|
||||
|
||||
|
||||
@receiver(post_save, sender=CalendarEvent)
|
||||
def generate_event_occurrences_on_save(sender, instance, created, **kwargs):
|
||||
instance.generate_occurrences()
|
||||
24
course_calendar/templates/admin_add_event.html
Normal file
24
course_calendar/templates/admin_add_event.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "wagtailadmin/base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block titletag %}
|
||||
{% trans "Add Event" %}
|
||||
{% endblock titletag %}
|
||||
|
||||
{% block content %}
|
||||
{{ form.media }}
|
||||
{% include "wagtailadmin/shared/header.html" with title="Events" icon="calendar" %}
|
||||
|
||||
<div style="margin: auto 72px;">
|
||||
<h2>Add event</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="button button-primary">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock content %}
|
||||
|
||||
|
||||
|
||||
27
course_calendar/templates/admin_events_dashboard.html
Normal file
27
course_calendar/templates/admin_events_dashboard.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "wagtailadmin/base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block titletag %}
|
||||
{% trans "Events" %}
|
||||
{% endblock titletag %}
|
||||
|
||||
{% block content %}
|
||||
{% include "wagtailadmin/shared/header.html" with title="Events" icon="calendar" %}
|
||||
|
||||
<div>
|
||||
<ul>
|
||||
{% for event in events %}
|
||||
|
||||
<li>
|
||||
{{ event | pprint }}
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="{% url 'admin_add_event' %}" class="button">{% trans "Add event" %}</a>
|
||||
|
||||
{% endblock content %}
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<script>
|
||||
function showModal(occurrenceId) {
|
||||
// get event's url
|
||||
const eventApiUrl = `/api/calendar/occurrences/${occurrenceId}`;
|
||||
const eventApiUrl = `/calendar/api/calendar/occurrences/${occurrenceId}`;
|
||||
|
||||
const eventApi = fetch(eventApiUrl)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
eventUrl = `/occurrence/${occurrenceId}/`;
|
||||
eventUrl = `/calendar/occurrence/${occurrenceId}/`;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.classList.add('fixed', 'inset-0', 'flex', 'items-center', 'justify-center', 'z-50', 'shadow-lg', 'overflow-auto');
|
||||
@@ -121,7 +121,7 @@
|
||||
right: 'timeGridWeek,listMonth',
|
||||
},
|
||||
locale: "{% get_current_language as LANGUAGE_CODE %}{{ LANGUAGE_CODE }}",
|
||||
events: "/api/calendar/occurrences/",
|
||||
events: "/calendar/api/calendar/occurrences/",
|
||||
eventClick: function(info) {
|
||||
// prevent default navigation
|
||||
info.jsEvent.preventDefault();
|
||||
3
course_calendar/tests.py
Normal file
3
course_calendar/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
36
course_calendar/urls.py
Normal file
36
course_calendar/urls.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.calendar, name="calendar_view"),
|
||||
path(
|
||||
"occurrence/<int:occurrence_id>/",
|
||||
views.occurrence_detail,
|
||||
name="occurrence_detail",
|
||||
),
|
||||
path(
|
||||
"api/calendar/events/",
|
||||
views.get_all_events,
|
||||
name="get_all_events",
|
||||
),
|
||||
path(
|
||||
"api/calendar/occurrences/",
|
||||
views.get_calendar_occurrences,
|
||||
name="get_calendar_occurrences",
|
||||
),
|
||||
path(
|
||||
"api/calendar/occurrences/<int:occurrence_id>/",
|
||||
views.get_calendar_occurrence,
|
||||
name="get_calendar_occurrence",
|
||||
),
|
||||
path(
|
||||
"occurrence/<int:occurrence_id>/signup/",
|
||||
views.occurrence_signup,
|
||||
name="occurrence_signup",
|
||||
),
|
||||
path(
|
||||
"occurrence/<int:occurrence_id>/signout/",
|
||||
views.occurrence_signout,
|
||||
name="occurrence_signout",
|
||||
),
|
||||
]
|
||||
156
course_calendar/views.py
Normal file
156
course_calendar/views.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from django import urls
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from course_calendar.forms import EventForm
|
||||
|
||||
from .models import CalendarEvent, EventOccurrence
|
||||
|
||||
|
||||
def calendar(request):
|
||||
return render(request, "calendar.html")
|
||||
|
||||
|
||||
def admin_events_dashboard(request):
|
||||
return render(
|
||||
request,
|
||||
"admin_events_dashboard.html",
|
||||
context={
|
||||
"events": EventOccurrence.objects.select_related("event").all(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def admin_add_event(request):
|
||||
if request.method == "POST":
|
||||
form = EventForm(request.POST)
|
||||
if form.is_valid():
|
||||
print("CLEANED DATA:", form.cleaned_data)
|
||||
event = form.save()
|
||||
print("Event created:", event.__dict__)
|
||||
# Save many-to-many data if present
|
||||
if hasattr(form, "save_m2m"):
|
||||
form.save_m2m()
|
||||
return redirect("admin_events_dashboard")
|
||||
else:
|
||||
print("FORM ERRORS:", form.errors)
|
||||
|
||||
form = EventForm()
|
||||
|
||||
return render(request, "admin_add_event.html", {"form": form})
|
||||
|
||||
|
||||
def occurrence_detail(request, occurrence_id):
|
||||
occ = (
|
||||
EventOccurrence.objects.select_related("event").filter(id=occurrence_id).first()
|
||||
)
|
||||
if not occ:
|
||||
return redirect("calendar")
|
||||
event = occ.event.specific
|
||||
return render(
|
||||
request,
|
||||
"occurrence_detail.html",
|
||||
{
|
||||
"occurrence": occ,
|
||||
"event": event,
|
||||
"user_signed_up": occ.user_signed_up(request.user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_all_events(request):
|
||||
events = EventOccurrence.objects.select_related("event").all()
|
||||
events_list = []
|
||||
for occ in events:
|
||||
event = occ.event.specific
|
||||
events_list.append(
|
||||
{
|
||||
"id": occ.id,
|
||||
"event_id": event.id,
|
||||
"title": event.title,
|
||||
"start": occ.start,
|
||||
"end": occ.end,
|
||||
"location": event.location,
|
||||
"url": event.url,
|
||||
"color": "#666666" if occ.is_past else event.color,
|
||||
"tags": list(event.tags.values_list("name", flat=True)),
|
||||
}
|
||||
)
|
||||
return JsonResponse(events_list, safe=False)
|
||||
|
||||
|
||||
def get_calendar_occurrences(request):
|
||||
# get occurrences from database (EventOccurrence model)
|
||||
start = request.GET.get("start")
|
||||
end = request.GET.get("end")
|
||||
occurrences = EventOccurrence.objects.filter(
|
||||
start__gte=start, end__lte=end
|
||||
).select_related("event")
|
||||
|
||||
events_list = []
|
||||
for occ in occurrences:
|
||||
event = occ.event.specific
|
||||
events_list.append(
|
||||
{
|
||||
"id": occ.id,
|
||||
"event_id": event.id,
|
||||
"title": event.title,
|
||||
"start": occ.start,
|
||||
"end": occ.end,
|
||||
"location": event.location,
|
||||
"url": event.url,
|
||||
"color": "#666666" if occ.is_past else event.color,
|
||||
"tags": list(event.tags.values_list("name", flat=True)),
|
||||
}
|
||||
)
|
||||
|
||||
return JsonResponse(events_list, safe=False)
|
||||
|
||||
|
||||
def get_calendar_occurrence(request, occurrence_id):
|
||||
occ = (
|
||||
EventOccurrence.objects.select_related("event").filter(id=occurrence_id).first()
|
||||
)
|
||||
if not occ:
|
||||
return JsonResponse({"error": "Occurrence not found"}, status=404)
|
||||
event = occ.event.specific
|
||||
event_dict = {
|
||||
"id": occ.id,
|
||||
"event_id": event.id,
|
||||
"title": event.title,
|
||||
"start": occ.start,
|
||||
"end": occ.end,
|
||||
"location": event.location,
|
||||
"url": event.url,
|
||||
"color": "#666666" if occ.is_past else event.color,
|
||||
"tags": list(event.tags.values_list("name", flat=True)),
|
||||
"attendees_count": occ.attendees_count,
|
||||
}
|
||||
return JsonResponse(event_dict)
|
||||
|
||||
|
||||
def occurrence_signup(request, occurrence_id):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("login")
|
||||
|
||||
occ = EventOccurrence.objects.filter(id=occurrence_id).first()
|
||||
if not occ:
|
||||
return redirect("calendar")
|
||||
|
||||
occ.signed_up_users.add(request.user)
|
||||
occ.save()
|
||||
# redirect to calendar page with ?modal=occurrence_id to show modal with event details
|
||||
return redirect(urls.reverse("calendar") + f"?modal={occurrence_id}")
|
||||
|
||||
|
||||
def occurrence_signout(request, occurrence_id):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("login")
|
||||
|
||||
occ = EventOccurrence.objects.filter(id=occurrence_id).first()
|
||||
if not occ:
|
||||
return redirect("calendar")
|
||||
|
||||
occ.signed_up_users.remove(request.user)
|
||||
occ.save()
|
||||
return redirect(urls.reverse("calendar") + f"?modal={occurrence_id}")
|
||||
27
course_calendar/wagtail_hooks.py
Normal file
27
course_calendar/wagtail_hooks.py
Normal file
@@ -0,0 +1,27 @@
|
||||
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_admin_urls")
|
||||
def register_events_form_url():
|
||||
return [
|
||||
path("events/", views.admin_events_dashboard, name="admin_events_dashboard"),
|
||||
path(
|
||||
"events/add/",
|
||||
views.admin_add_event,
|
||||
name="admin_add_event",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@hooks.register("register_admin_menu_item")
|
||||
def register_admin_chat_menu_item():
|
||||
return MenuItem(
|
||||
"Event Calendar", reverse("admin_events_dashboard"), icon_name="calendar"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
27
home/migrations/0016_modulelessonpage.py
Normal file
27
home/migrations/0016_modulelessonpage.py
Normal 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',),
|
||||
),
|
||||
]
|
||||
26
home/migrations/0017_chatmessage.py
Normal file
26
home/migrations/0017_chatmessage.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
25
home/migrations/0018_courseindexpage.py
Normal file
25
home/migrations/0018_courseindexpage.py
Normal 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',),
|
||||
),
|
||||
]
|
||||
18
home/migrations/0019_coursepage_description.py
Normal file
18
home/migrations/0019_coursepage_description.py
Normal 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),
|
||||
),
|
||||
]
|
||||
25
home/migrations/0020_coursepage_repository_url_and_more.py
Normal file
25
home/migrations/0020_coursepage_repository_url_and_more.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.0.3 on 2026-03-23 11:59
|
||||
|
||||
import modelcluster.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('home', '0019_coursepage_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='coursepage',
|
||||
name='repository_url',
|
||||
field=models.URLField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='coursepage',
|
||||
name='allowed_groups',
|
||||
field=modelcluster.fields.ParentalManyToManyField(help_text="Additional groups that should have access to this course, e.g. Editors. NOTE: Users who purchase the course will be automatically added to a dedicated access group for this course, so you don't need to add that group here.", related_name='course_pages', to='auth.group'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.3 on 2026-03-30 07:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0020_coursepage_repository_url_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='modulelessonpage',
|
||||
name='create_gitea_repo',
|
||||
field=models.BooleanField(default=False, help_text='If enabled, a Gitea repository will be automatically created for this module when the module is published.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='modulelessonpage',
|
||||
name='gitea_repo_url',
|
||||
field=models.URLField(blank=True, help_text="URL of the Gitea repository for this lesson (auto-generated if 'create_gitea_repo' is enabled)", null=True),
|
||||
),
|
||||
]
|
||||
40
home/migrations/0022_blogindexpage_blogpage.py
Normal file
40
home/migrations/0022_blogindexpage_blogpage.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 6.0.3 on 2026-03-30 07:53
|
||||
|
||||
import django.db.models.deletion
|
||||
import wagtail.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0021_modulelessonpage_create_gitea_repo_and_more'),
|
||||
('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'),
|
||||
('wagtailimages', '0027_image_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BlogIndexPage',
|
||||
fields=[
|
||||
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('wagtailcore.page',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BlogPage',
|
||||
fields=[
|
||||
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||
('author', models.CharField(max_length=255)),
|
||||
('body', wagtail.fields.StreamField([('heading', 0), ('paragraph', 1), ('image', 2)], block_lookup={0: ('wagtail.blocks.CharBlock', (), {'form_classname': 'title'}), 1: ('wagtail.blocks.RichTextBlock', (), {}), 2: ('wagtail.images.blocks.ImageBlock', [], {})})),
|
||||
('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('wagtailcore.page',),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 6.0.3 on 2026-03-30 09:30
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('home', '0022_blogindexpage_blogpage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='eventpage',
|
||||
name='hosts',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='eventpage',
|
||||
name='image',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='eventpage',
|
||||
name='page_ptr',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='eventpage',
|
||||
name='tags',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='eventpagetag',
|
||||
name='content_object',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='eventpagetag',
|
||||
name='tag',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='EventOccurrence',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='EventPage',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='EventPageTag',
|
||||
),
|
||||
]
|
||||
19
home/models/__init__.py
Normal file
19
home/models/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .pages import (
|
||||
EmptyPage,
|
||||
HomePage,
|
||||
CoursePage,
|
||||
CourseModulePage,
|
||||
ModuleLessonPage,
|
||||
)
|
||||
|
||||
from .chat_message import ChatMessage
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HomePage",
|
||||
"EmptyPage",
|
||||
"CoursePage",
|
||||
"CourseModulePage",
|
||||
"ModuleLessonPage",
|
||||
"ChatMessage",
|
||||
]
|
||||
26
home/models/chat_message.py
Normal file
26
home/models/chat_message.py
Normal 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)
|
||||
223
home/models/pages.py
Normal file
223
home/models/pages.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import models
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
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 purchase.models import CoursePurchase
|
||||
|
||||
|
||||
class EmptyPage(Page):
|
||||
pass
|
||||
|
||||
|
||||
class HomePage(Page):
|
||||
body = RichTextField(blank=True)
|
||||
|
||||
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):
|
||||
course_image = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
blank=True,
|
||||
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="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)
|
||||
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):
|
||||
body = RichTextField(blank=True)
|
||||
|
||||
@property
|
||||
def course(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):
|
||||
course = self.course
|
||||
if course:
|
||||
return f"{course.title} - {self.title}"
|
||||
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",
|
||||
),
|
||||
]
|
||||
parent_page_types = ["home.CourseModulePage"]
|
||||
216
home/signals.py
Normal file
216
home/signals.py
Normal file
@@ -0,0 +1,216 @@
|
||||
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, ModuleLessonPage
|
||||
|
||||
GITEA_ORG_NAME = "Studio77"
|
||||
|
||||
logger = lg.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=CoursePage)
|
||||
def create_gitea_team_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
|
||||
|
||||
# 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,
|
||||
# )
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
@receiver(post_save, sender=ModuleLessonPage)
|
||||
def create_gitea_repo_on_lesson_creation(
|
||||
sender, instance: ModuleLessonPage, created, **kwargs
|
||||
):
|
||||
if not instance.live:
|
||||
logger.debug(
|
||||
f"Lesson {instance.title} is not live, skipping Gitea repository creation"
|
||||
)
|
||||
return
|
||||
|
||||
course = instance.module.course
|
||||
repo_name = f"course-{course.id}-lesson-{instance.id}"
|
||||
|
||||
if not course.live:
|
||||
logger.debug(
|
||||
f"Course {course.title} is not live, skipping Gitea repository creation for lesson {instance.title}"
|
||||
)
|
||||
return
|
||||
|
||||
api_url = getattr(settings, "GITEA_URL", None)
|
||||
|
||||
if not api_url:
|
||||
logger.debug("GITEA_URL is not set, skipping Gitea repository creation")
|
||||
return
|
||||
|
||||
# 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"] == repo_name for repo in repos):
|
||||
logger.info(
|
||||
f"Gitea repository {repo_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 lesson repository
|
||||
url = f"{api_url}/orgs/{GITEA_ORG_NAME}/repos"
|
||||
payload = {
|
||||
"auto_init": True,
|
||||
"default_branch": "main",
|
||||
"description": f"{instance.module.course} – {instance.module}: {instance.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)
|
||||
instance.gitea_repo_url = repo_url
|
||||
instance.save()
|
||||
logger.info(
|
||||
f"Successfully created Gitea repository for lesson {instance.title}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to create Gitea repository for lesson {instance.title}: {e}\n{response.text}",
|
||||
e,
|
||||
)
|
||||
29
home/templates/chat/admin/admin_chat.html
Normal file
29
home/templates/chat/admin/admin_chat.html
Normal 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 %}
|
||||
|
||||
|
||||
23
home/templates/chat/admin/admin_chat_dashboard.html
Normal file
23
home/templates/chat/admin/admin_chat_dashboard.html
Normal 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 %}
|
||||
|
||||
26
home/templates/chat/user_chat.html
Normal file
26
home/templates/chat/user_chat.html
Normal 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 %}
|
||||
31
home/templates/home/blog_index_page.html
Normal file
31
home/templates/home/blog_index_page.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static i18n wagtailcore_tags wagtailimages_tags %}
|
||||
|
||||
{% block title %}{% trans "Blog" %}{% endblock title %}
|
||||
|
||||
{% block body_class %}template-courseindex{% endblock body_class %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-3xl font-bold mb-6 text-center">{% trans "Blog" %}</h1>
|
||||
|
||||
<div class="flex flex-wrap -mx-4">
|
||||
{% for post in posts %}
|
||||
<div class="w-full md:w-1/2 lg:w-1/3 px-4 mb-8">
|
||||
<a href="{{ post.url }}" class="block bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
|
||||
{% if post.blog_image %}
|
||||
{% image post.blog_image original alt=post.title class="w-full h-auto" %}
|
||||
{% endif %}
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-semibold">{{ post.title }}</h2>
|
||||
<p class="text-gray-600">{{ post.excerpt|truncatewords:20 }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-center text-gray-600 italic">{% trans "No blog posts yet." %}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endblock content %}
|
||||
|
||||
0
home/templates/home/blog_page.html
Normal file
0
home/templates/home/blog_page.html
Normal file
52
home/templates/home/course_index_page.html
Normal file
52
home/templates/home/course_index_page.html
Normal 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 %}
|
||||
@@ -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> » {{ page.title }}
|
||||
<a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
|
||||
» <a href="{{ page.course.url }}" class="font-bold hover:underline">{{ page.course.title }}</a>
|
||||
» {{ page.title }}
|
||||
</h2>
|
||||
|
||||
{{ page.body|richtext }}
|
||||
|
||||
<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 %}
|
||||
|
||||
@@ -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>
|
||||
» {{ 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 %}
|
||||
|
||||
24
home/templates/home/module_lesson_page.html
Normal file
24
home/templates/home/module_lesson_page.html
Normal 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>
|
||||
» <a href="{{ page.module.course.url }}" class="font-bold hover:underline">{{ page.module.course.title }}</a>
|
||||
» <a href="{{ page.module.url }}" class="font-bold hover:underline">{{ page.module.title }}</a>
|
||||
» <span class="text-gray-500">{{ page.title }}</span>
|
||||
</h2>
|
||||
|
||||
{{ page.body|richtext }}
|
||||
{% endblock content %}
|
||||
|
||||
@@ -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
7
home/urls.py
Normal 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
44
home/views.py
Normal 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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
11
kursy/oauth_validators.py
Normal 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,
|
||||
}
|
||||
@@ -30,8 +30,10 @@ dotenv.load_dotenv(BASE_DIR / ".env")
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"course_calendar",
|
||||
"home",
|
||||
"search",
|
||||
"purchase",
|
||||
"wagtail.contrib.forms",
|
||||
"wagtail.contrib.redirects",
|
||||
"wagtail.embeds",
|
||||
@@ -58,6 +60,7 @@ INSTALLED_APPS = [
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth.socialaccount.providers.github",
|
||||
"oauth2_provider",
|
||||
"tailwind",
|
||||
"theme",
|
||||
"widget_tweaks",
|
||||
@@ -75,6 +78,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 +135,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 +242,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 +313,6 @@ WAGTAILDOCS_EXTENSIONS = [
|
||||
]
|
||||
|
||||
TAILWIND_APP_NAME = "theme"
|
||||
|
||||
# Gitea API
|
||||
GITEA_URL = "http://localhost:3000/api/v1"
|
||||
|
||||
@@ -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_view' %}" 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>
|
||||
|
||||
@@ -20,32 +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("calendar/", views.calendar, name="calendar"),
|
||||
path(
|
||||
"occurrence/<int:occurrence_id>/",
|
||||
views.occurrence_detail,
|
||||
name="occurrence_detail",
|
||||
),
|
||||
path(
|
||||
"api/calendar/occurrences/",
|
||||
views.get_calendar_occurrences,
|
||||
name="get_calendar_occurrences",
|
||||
),
|
||||
path(
|
||||
"api/calendar/occurrences/<int:occurrence_id>/",
|
||||
views.get_calendar_occurrence,
|
||||
name="get_calendar_occurrence",
|
||||
),
|
||||
path(
|
||||
"occurrence/<int:occurrence_id>/signup/",
|
||||
views.occurrence_signup,
|
||||
name="occurrence_signup",
|
||||
),
|
||||
path(
|
||||
"occurrence/<int:occurrence_id>/signout/",
|
||||
views.occurrence_signout,
|
||||
name="occurrence_signout",
|
||||
),
|
||||
path("oauth2/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||
path("", include("home.urls")),
|
||||
path("", include("purchase.urls")),
|
||||
path("calendar/", include("course_calendar.urls"), name="calendar"),
|
||||
# TODO: move occurrence related urls to home app
|
||||
]
|
||||
|
||||
|
||||
|
||||
101
kursy/views.py
101
kursy/views.py
@@ -3,7 +3,7 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from home.models import EventOccurrence, EventPage
|
||||
# from home.models import EventOccurrence, EventPage
|
||||
|
||||
from .forms import SignUpForm
|
||||
|
||||
@@ -23,102 +23,3 @@ def signup(request):
|
||||
@login_required
|
||||
def profile(request):
|
||||
return render(request, "profile.html", {"user": request.user})
|
||||
|
||||
|
||||
def calendar(request):
|
||||
return render(request, "calendar.html")
|
||||
|
||||
|
||||
def occurrence_detail(request, occurrence_id):
|
||||
occ = (
|
||||
EventOccurrence.objects.select_related("event").filter(id=occurrence_id).first()
|
||||
)
|
||||
if not occ:
|
||||
return redirect("calendar")
|
||||
event = occ.event.specific
|
||||
return render(
|
||||
request,
|
||||
"occurrence_detail.html",
|
||||
{
|
||||
"occurrence": occ,
|
||||
"event": event,
|
||||
"user_signed_up": occ.user_signed_up(request.user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_calendar_occurrences(request):
|
||||
# get occurrences from database (EventOccurrence model)
|
||||
start = request.GET.get("start")
|
||||
end = request.GET.get("end")
|
||||
occurrences = EventOccurrence.objects.filter(
|
||||
start__gte=start, end__lte=end
|
||||
).select_related("event")
|
||||
|
||||
events_list = []
|
||||
for occ in occurrences:
|
||||
event = occ.event.specific
|
||||
events_list.append(
|
||||
{
|
||||
"id": occ.id,
|
||||
"event_id": event.id,
|
||||
"title": event.title,
|
||||
"start": occ.start,
|
||||
"end": occ.end,
|
||||
"location": event.location,
|
||||
"url": event.url,
|
||||
"color": "#666666" if occ.is_past else event.color,
|
||||
"tags": list(event.tags.values_list("name", flat=True)),
|
||||
}
|
||||
)
|
||||
|
||||
return JsonResponse(events_list, safe=False)
|
||||
|
||||
|
||||
def get_calendar_occurrence(request, occurrence_id):
|
||||
occ = (
|
||||
EventOccurrence.objects.select_related("event").filter(id=occurrence_id).first()
|
||||
)
|
||||
if not occ:
|
||||
return JsonResponse({"error": "Occurrence not found"}, status=404)
|
||||
event = occ.event.specific
|
||||
event_dict = {
|
||||
"id": occ.id,
|
||||
"event_id": event.id,
|
||||
"title": event.title,
|
||||
"start": occ.start,
|
||||
"end": occ.end,
|
||||
"location": event.location,
|
||||
"url": event.url,
|
||||
"color": "#666666" if occ.is_past else event.color,
|
||||
"tags": list(event.tags.values_list("name", flat=True)),
|
||||
"attendees_count": occ.attendees_count,
|
||||
}
|
||||
return JsonResponse(event_dict)
|
||||
|
||||
|
||||
def occurrence_signup(request, occurrence_id):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("login")
|
||||
|
||||
occ = EventOccurrence.objects.filter(id=occurrence_id).first()
|
||||
if not occ:
|
||||
return redirect("calendar")
|
||||
|
||||
occ.signed_up_users.add(request.user)
|
||||
occ.save()
|
||||
# redirect to calendar page with ?modal=occurrence_id to show modal with event details
|
||||
return redirect(urls.reverse("calendar") + f"?modal={occurrence_id}")
|
||||
|
||||
|
||||
def occurrence_signout(request, occurrence_id):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("login")
|
||||
|
||||
occ = EventOccurrence.objects.filter(id=occurrence_id).first()
|
||||
if not occ:
|
||||
return redirect("calendar")
|
||||
|
||||
occ.signed_up_users.remove(request.user)
|
||||
occ.save()
|
||||
return redirect(urls.reverse("calendar") + f"?modal={occurrence_id}")
|
||||
|
||||
0
purchase/__init__.py
Normal file
0
purchase/__init__.py
Normal file
3
purchase/admin.py
Normal file
3
purchase/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
purchase/apps.py
Normal file
5
purchase/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PurchaseConfig(AppConfig):
|
||||
name = 'purchase'
|
||||
28
purchase/migrations/0001_initial.py
Normal file
28
purchase/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
purchase/migrations/__init__.py
Normal file
0
purchase/migrations/__init__.py
Normal file
23
purchase/models.py
Normal file
23
purchase/models.py
Normal 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
3
purchase/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
16
purchase/urls.py
Normal file
16
purchase/urls.py
Normal 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
21
purchase/views.py
Normal 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)
|
||||
@@ -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",
|
||||
|
||||
11
theme/static_src/package-lock.json
generated
11
theme/static_src/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
30
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user