19 Commits

Author SHA1 Message Date
9c71dc1e47 --wip-- [skip ci] 2026-04-02 10:13:19 +02:00
dd936473d8 feat(signals.py): create per-lesson repositories on ModuleLessonPage save 2026-03-30 10:58:01 +02:00
787440d56f chore(migrations/0022): create BlogIndexPage and BlogPage 2026-03-30 10:13:12 +02:00
37b0a6a95b feat(pages.py): add blog pages 2026-03-30 10:13:12 +02:00
345914a519 chore(migrations/0021): add create_gitea_repo and gitea_repo_url to ModuleLessonPage 2026-03-30 10:13:12 +02:00
7e24ded8ee fix(pages.py): generate event occurrences only if live 2026-03-30 10:13:12 +02:00
64edf6656e feat(pages.py): add per-lesson repo fields 2026-03-30 10:13:03 +02:00
a2ad8e7ac9 feat(module_lesson_page.html): add link to course library 2026-03-23 14:03:38 +01:00
a0b4697c61 feat(course_module_page.html): add link to course library 2026-03-23 14:03:29 +01:00
983384f62b feat(course_page.html): add link to course library in CoursePage 2026-03-23 14:03:10 +01:00
668ddccea5 feat(settings/base.py): add LOGGING config 2026-03-23 14:02:24 +01:00
6dd826c3bd feat(home/signals.py): create gitea team and repo for course on CoursePage save 2026-03-23 14:02:07 +01:00
e74c1fb28d chore(migrations/0020): add repository_url field to CoursePage 2026-03-23 13:46:24 +01:00
cb19bc6262 feat(models/pages.py): add repository_url field to CoursePage 2026-03-23 13:45:19 +01:00
a918ee73c4 fix(models/pages.py): ensure course has ID before creating group 2026-03-23 13:44:33 +01:00
5913e847bc refactor(forms.py): move gitea account creation login to separate function 2026-03-20 14:47:12 +01:00
18b21b0892 feat(forms.py): create gitea account on signup 2026-03-20 14:37:40 +01:00
efb3799e12 feat(forms.py): capitalize first and last name 2026-03-20 14:37:22 +01:00
306d39bd22 feat(oauth_validators.py): use user ID for gitea username 2026-03-20 14:36:27 +01:00
35 changed files with 1096 additions and 388 deletions

View File

6
course_calendar/admin.py Normal file
View 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
View File

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

21
course_calendar/forms.py Normal file
View 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"}),
}

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

View File

179
course_calendar/models.py Normal file
View File

@@ -0,0 +1,179 @@
from datetime import datetime, timedelta
import logging
from django.contrib.auth.models import User
from django.db import models
from django.forms.widgets import CheckboxSelectMultiple
from django.utils import timezone
from modelcluster.contrib.taggit import ClusterTaggableManager
from taggit.models import TaggedItemBase
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 EventTag(TaggedItemBase):
content_object = models.ForeignKey(
"course_calendar.CalendarEvent",
related_name="tagged_items",
on_delete=models.CASCADE,
)
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",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
start = models.DateTimeField(
blank=True,
null=True,
help_text="Start date and time of the singular event (ignored if recurrence is enabled)",
)
end = models.DateTimeField(
blank=True,
null=True,
help_text="End date and time of the singular event (ignored if recurrence is enabled)",
)
# Recurrence fields
recurrence_enabled = models.BooleanField(
default=False, help_text="Enable automatic recurrence for this event"
)
RECURRENCE_DAYS = [
(0, "Monday"),
(1, "Tuesday"),
(2, "Wednesday"),
(3, "Thursday"),
(4, "Friday"),
(5, "Saturday"),
(6, "Sunday"),
]
recurrence_days_of_week = models.JSONField(
default=list,
blank=True,
help_text="Days of the week for recurrence (e.g., [0,3] for Mon & Thu)",
)
recurrence_start_time = models.TimeField(
null=True, blank=True, help_text="Start time for each occurrence"
)
recurrence_end_time = models.TimeField(
null=True, blank=True, help_text="End time for each occurrence"
)
recurrence_repeat_until = models.DateField(
null=True, blank=True, help_text="Repeat until this date (inclusive)"
)
recurrence_endless = models.BooleanField(
default=False,
help_text="If enabled, recurrence will not end (ignore 'repeat until' date)",
)
# TODO: use google maps here
location = models.CharField(max_length=255, blank=True)
max_attendees = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Maximum number of attendees. Leave blank for unlimited.",
)
description = RichTextField(blank=True)
hosts = models.ManyToManyField(
User,
related_name="hosted_events",
help_text="Select users who will be listed as hosts of this event.",
)
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
if self.occurrences.exists():
occurrence = self.occurrences.first()
if occurrence.start != self.start or occurrence.end != self.end:
occurrence.start = self.start
occurrence.end = self.end
occurrence.save(update_fields=["start", "end"])
else:
EventOccurrence.objects.create(
event=self, start=self.start, end=self.end
)
return
# Determine the date range
start_date = now.date()
if self.recurrence_endless:
end_date = start_date + timedelta(days=days_ahead)
elif self.recurrence_repeat_until:
end_date = self.recurrence_repeat_until
else:
end_date = start_date + timedelta(days=days_ahead)
days_of_week = self.recurrence_days_of_week or []
start_time = self.recurrence_start_time or datetime.min.time()
end_time = self.recurrence_end_time or datetime.min.time()
for single_date in (
start_date + timedelta(n) for n in range((end_date - start_date).days + 1)
):
if days_of_week and single_date.weekday() not in days_of_week:
continue
start_dt = datetime.combine(single_date, start_time)
end_dt = datetime.combine(single_date, end_time)
start_dt = timezone.make_aware(start_dt)
end_dt = timezone.make_aware(end_dt)
# If an occurrence exists, update its start/end if needed, but keep signed_up_users
occurrence = self.occurrences.filter(start=start_dt, end=end_dt).first()
if occurrence:
if occurrence.start != start_dt or occurrence.end != end_dt:
occurrence.start = start_dt
occurrence.end = end_dt
occurrence.save(update_fields=["start", "end"])
else:
EventOccurrence.objects.create(event=self, start=start_dt, end=end_dt)
class EventOccurrence(models.Model):
event = models.ForeignKey(
CalendarEvent, related_name="occurrences", on_delete=models.CASCADE
)
start = models.DateTimeField()
end = models.DateTimeField()
signed_up_users = models.ManyToManyField(
User,
related_name="event_occurrences_signed_up",
blank=True,
help_text="Users who have signed up for this occurrence.",
)
class Meta:
ordering = ["start"]
def __str__(self):
return f"{self.event.title} ({self.start} - {self.end})"
@property
def attendees_count(self):
return self.signed_up_users.count()
@property
def is_past(self):
return self.end < timezone.now()
def user_signed_up(self, user):
if not user.is_authenticated:
return False
return self.signed_up_users.filter(id=user.id).exists()

View File

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

View 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 %}

View 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 %}

View File

@@ -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
View File

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

36
course_calendar/urls.py Normal file
View 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
View 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}")

View 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"
)

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-23 11:59
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('home', '0019_coursepage_description'),
]
operations = [
migrations.AddField(
model_name='coursepage',
name='repository_url',
field=models.URLField(blank=True, null=True),
),
migrations.AlterField(
model_name='coursepage',
name='allowed_groups',
field=modelcluster.fields.ParentalManyToManyField(help_text="Additional groups that should have access to this course, e.g. Editors. NOTE: Users who purchase the course will be automatically added to a dedicated access group for this course, so you don't need to add that group here.", related_name='course_pages', to='auth.group'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-30 07:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0020_coursepage_repository_url_and_more'),
]
operations = [
migrations.AddField(
model_name='modulelessonpage',
name='create_gitea_repo',
field=models.BooleanField(default=False, help_text='If enabled, a Gitea repository will be automatically created for this module when the module is published.'),
),
migrations.AddField(
model_name='modulelessonpage',
name='gitea_repo_url',
field=models.URLField(blank=True, help_text="URL of the Gitea repository for this lesson (auto-generated if 'create_gitea_repo' is enabled)", null=True),
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 6.0.3 on 2026-03-30 07:53
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0021_modulelessonpage_create_gitea_repo_and_more'),
('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='BlogIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='BlogPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('author', models.CharField(max_length=255)),
('body', wagtail.fields.StreamField([('heading', 0), ('paragraph', 1), ('image', 2)], block_lookup={0: ('wagtail.blocks.CharBlock', (), {'form_classname': 'title'}), 1: ('wagtail.blocks.RichTextBlock', (), {}), 2: ('wagtail.images.blocks.ImageBlock', [], {})})),
('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
]

View File

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

View File

@@ -4,11 +4,8 @@ from .pages import (
CoursePage,
CourseModulePage,
ModuleLessonPage,
EventPage,
)
from .event_occurrence import EventOccurrence
from .chat_message import ChatMessage
@@ -18,7 +15,5 @@ __all__ = [
"CoursePage",
"CourseModulePage",
"ModuleLessonPage",
"EventPage",
"EventOccurrence",
"ChatMessage",
]

View File

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

View File

@@ -1,19 +1,12 @@
from datetime import datetime, timedelta
from django.contrib.auth.models import Group, User
from django.conf import settings
from django.contrib.auth.models import Group
from django.db import models
from django.forms import CheckboxSelectMultiple
from django.utils import timezone
from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase
from wagtail import blocks
from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField
from wagtail.fields import RichTextField, StreamField
from wagtail.images.blocks import ImageBlock
from wagtail.models import Page
from wagtail.models.copying import ParentalManyToManyField
from wagtail_color_panel.edit_handlers import NativeColorPanel
from wagtail_color_panel.fields import ColorField
from purchase.models import CoursePurchase
@@ -28,6 +21,10 @@ class HomePage(Page):
content_panels = Page.content_panels + ["body"]
class BlogIndexPage(Page):
subpage_types = ["home.BlogPage"]
class CourseIndexPage(Page):
subpage_types = ["home.CoursePage"]
@@ -50,6 +47,31 @@ class CourseIndexPage(Page):
return context
class BlogPage(Page):
author = models.CharField(max_length=255)
image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
body = StreamField(
[
("heading", blocks.CharBlock(classname="title")),
("paragraph", blocks.RichTextBlock()),
("image", ImageBlock()),
]
)
content_panels = Page.content_panels + [
FieldPanel("author"),
FieldPanel("image"),
FieldPanel("body"),
]
parent_page_types = ["home.BlogIndexPage"]
class CoursePage(Page):
course_image = models.ForeignKey(
"wagtailimages.Image",
@@ -66,6 +88,11 @@ class CoursePage(Page):
help_text="Additional groups that should have access to this course, e.g. Editors. NOTE: Users who purchase the course will be automatically added to a dedicated access group for this course, so you don't need to add that group here.",
)
repository_url = models.URLField(
null=True,
blank=True,
)
def _user_has_access(self, user):
if not user.is_authenticated:
return False
@@ -105,11 +132,11 @@ class CoursePage(Page):
return created
def save(self, *args, **kwargs):
group_name = f"course_{self.id}_access"
group, created = Group.objects.get_or_create(name=group_name)
if state := not self.allowed_groups.filter(id=group.id).exists():
print(state)
self.allowed_groups.add(group)
if self.id is not None:
group_name = f"course_{self.id}_access"
group, created = Group.objects.get_or_create(name=group_name)
if not self.allowed_groups.filter(id=group.id).exists():
self.allowed_groups.add(group)
super().save(*args, **kwargs)
@@ -124,6 +151,11 @@ class CoursePage(Page):
FieldPanel("description"),
FieldPanel("body"),
FieldPanel("allowed_groups", widget=CheckboxSelectMultiple),
FieldPanel(
"repository_url",
read_only=True,
heading="Repository URL (auto-generated)",
),
]
parent_page_types = ["home.CourseIndexPage"]
subpage_types = ["home.CourseModulePage"]
@@ -154,6 +186,15 @@ class CourseModulePage(Page):
class ModuleLessonPage(Page):
body = RichTextField(blank=True)
create_gitea_repo = models.BooleanField(
default=False,
help_text="If enabled, a Gitea repository will be automatically created for this module when the module is published.",
)
gitea_repo_url = models.URLField(
null=True,
blank=True,
help_text="URL of the Gitea repository for this lesson (auto-generated if 'create_gitea_repo' is enabled)",
)
@property
def module(self):
@@ -170,162 +211,13 @@ class ModuleLessonPage(Page):
return f"{module.full_title} - {self.title}"
return self.title
content_panels = Page.content_panels + ["body"]
parent_page_types = ["home.CourseModulePage"]
class EventPageTag(TaggedItemBase):
content_object = ParentalKey(
"home.EventPage",
related_name="tagged_items",
on_delete=models.CASCADE,
)
class EventPage(Page):
tags = ClusterTaggableManager(through=EventPageTag, blank=True)
color = ColorField(default="#1c398e")
image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
start = models.DateTimeField(
blank=True,
null=True,
help_text="Start date and time of the singular event (ignored if recurrence is enabled)",
)
end = models.DateTimeField(
blank=True,
null=True,
help_text="End date and time of the singular event (ignored if recurrence is enabled)",
)
# Recurrence fields
recurrence_enabled = models.BooleanField(
default=False, help_text="Enable automatic recurrence for this event"
)
RECURRENCE_DAYS = [
(0, "Monday"),
(1, "Tuesday"),
(2, "Wednesday"),
(3, "Thursday"),
(4, "Friday"),
(5, "Saturday"),
(6, "Sunday"),
]
recurrence_days_of_week = models.JSONField(
default=list,
blank=True,
help_text="Days of the week for recurrence (e.g., [0,3] for Mon & Thu)",
)
recurrence_start_time = models.TimeField(
null=True, blank=True, help_text="Start time for each occurrence"
)
recurrence_end_time = models.TimeField(
null=True, blank=True, help_text="End time for each occurrence"
)
recurrence_repeat_until = models.DateField(
null=True, blank=True, help_text="Repeat until this date (inclusive)"
)
recurrence_endless = models.BooleanField(
default=False,
help_text="If enabled, recurrence will not end (ignore 'repeat until' date)",
)
# TODO: use google maps here
location = models.CharField(max_length=255, blank=True)
max_attendees = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Maximum number of attendees. Leave blank for unlimited.",
)
description = RichTextField(blank=True)
hosts = ParentalManyToManyField(
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
if self.occurrences.exists():
occurrence = self.occurrences.first()
if occurrence.start != self.start or occurrence.end != self.end:
occurrence.start = self.start
occurrence.end = self.end
occurrence.save(update_fields=["start", "end"])
else:
EventOccurrence.objects.create(
event=self, start=self.start, end=self.end
)
return
# Determine the date range
start_date = now.date()
if self.recurrence_endless:
end_date = start_date + timedelta(days=days_ahead)
elif self.recurrence_repeat_until:
end_date = self.recurrence_repeat_until
else:
end_date = start_date + timedelta(days=days_ahead)
days_of_week = self.recurrence_days_of_week or []
start_time = self.recurrence_start_time or datetime.min.time()
end_time = self.recurrence_end_time or datetime.min.time()
for single_date in (
start_date + timedelta(n) for n in range((end_date - start_date).days + 1)
):
if days_of_week and single_date.weekday() not in days_of_week:
continue
start_dt = datetime.combine(single_date, start_time)
end_dt = datetime.combine(single_date, end_time)
start_dt = timezone.make_aware(start_dt)
end_dt = timezone.make_aware(end_dt)
# If an occurrence exists, update its start/end if needed, but keep signed_up_users
occurrence = self.occurrences.filter(start=start_dt, end=end_dt).first()
if occurrence:
if occurrence.start != start_dt or occurrence.end != end_dt:
occurrence.start = start_dt
occurrence.end = end_dt
occurrence.save(update_fields=["start", "end"])
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"),
FieldPanel("body"),
FieldPanel("create_gitea_repo"),
FieldPanel(
"gitea_repo_url",
read_only=True,
heading="Gitea Repository URL",
),
]
parent_page_types = ["home.CourseModulePage"]

View File

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

View 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 %}

View File

View File

@@ -13,7 +13,9 @@
{% block content %}
<h2 class="not-prose text-xl mb-4 text-gray-700">
<a href="{{ page.course.url }}" class="font-bold">{{ page.course.title }}</a> &raquo; {{ page.title }}
<a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; <a href="{{ page.course.url }}" class="font-bold hover:underline">{{ page.course.title }}</a>
&raquo; {{ page.title }}
</h2>
{{ page.body|richtext }}

View File

@@ -12,8 +12,9 @@
{% block content_class %}prose{% endblock content_class %}
{% block content %}
<h1 class="not-prose text-3xl mb-4 text-gray-700 font-bold">
{{ page.title }}
<h1 class="not-prose text-3xl mb-4 text-gray-700">
<a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; {{ page.title }}
</h1>
{% if page.course_image %}

View File

@@ -13,11 +13,10 @@
{% block content %}
<h2 class="not-prose text-xl mb-4 text-gray-700">
<a href="{{ page.module.course.url }}" class="font-bold">{{ page.module.course.title }}</a>
&raquo;
<a href="{{ page.module.url }}" class="font-bold">{{ page.module.title }}</a>
&raquo;
<span class="text-gray-500">{{ page.title }}</span>
<a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; <a href="{{ page.module.course.url }}" class="font-bold hover:underline">{{ page.module.course.title }}</a>
&raquo; <a href="{{ page.module.url }}" class="font-bold hover:underline">{{ page.module.title }}</a>
&raquo; <span class="text-gray-500">{{ page.title }}</span>
</h2>
{{ page.body|richtext }}

View File

@@ -1,13 +1,49 @@
import os
import requests
from django import forms
from django.conf import settings
from django.core.handlers.wsgi import WSGIRequest
def create_gitea_account(user, password):
payload = {
"user_id": user.id,
"username": f"studio77-{user.id}",
"email": user.email,
"full_name": f"{user.first_name} {user.last_name}".strip(),
"password": password,
"must_change_password": False,
"visibility": "private",
}
api_url = getattr(settings, "GITEA_URL", None)
if api_url:
url = f"{api_url}/admin/users"
try:
response = requests.post(
url,
json=payload,
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
print(f"Successfully created Gitea account for {user.email}")
except Exception as e:
print(
f"Failed to create Gitea account for user {user.email}: {e}\n{response.text}"
)
raise e
class SignUpForm(forms.Form):
first_name = forms.CharField(max_length=60, required=True, label="First Name")
last_name = forms.CharField(max_length=60, required=True, label="Last Name")
def signup(self, request, user):
user.first_name = self.cleaned_data["first_name"]
user.last_name = self.cleaned_data["last_name"]
def signup(self, request: WSGIRequest, user):
user.first_name = self.cleaned_data["first_name"].strip().title()
user.last_name = self.cleaned_data["last_name"].strip().title()
user.save()
return user
# gitea account creation
password = request.POST.get("password1")
create_gitea_account(user, password)

View File

@@ -6,6 +6,6 @@ class CustomOAuth2Validator(OAuth2Validator):
print("get_additional_claims", request.user)
return {
"name": " ".join([request.user.first_name, request.user.last_name]),
"preferred_username": f"studio77-{request.user.username}",
"preferred_username": f"studio77-{request.user.id}",
"email": request.user.email,
}

View File

@@ -30,6 +30,7 @@ dotenv.load_dotenv(BASE_DIR / ".env")
# Application definition
INSTALLED_APPS = [
"course_calendar",
"home",
"search",
"purchase",
@@ -241,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"

View File

@@ -5,7 +5,7 @@
<nav class="flex items-center gap-4">
<a class="text-xl font-bold" href="/">{{ current_site.site_name }}</a>
<a href="{% slugurl 'courses' %}" class="hover:underline">{% trans "Courses" %}</a>
<a href="{% url 'calendar' %}" class="hover:underline">{% trans "Calendar" %}</a>
<a href="{% url 'calendar_view' %}" class="hover:underline">{% trans "Calendar" %}</a>
</nav>
<nav class="flex items-center gap-4">

View File

@@ -23,33 +23,8 @@ urlpatterns = [
path("oauth2/", include("oauth2_provider.urls", namespace="oauth2_provider")),
path("", include("home.urls")),
path("", include("purchase.urls")),
path("calendar/", views.calendar, name="calendar"),
path("calendar/", include("course_calendar.urls"), name="calendar"),
# TODO: move occurrence related urls to home app
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",
),
]

View File

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