Compare commits
7 Commits
feat/add-b
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c71dc1e47 | |||
|
dd936473d8
|
|||
|
787440d56f
|
|||
|
37b0a6a95b
|
|||
|
345914a519
|
|||
|
7e24ded8ee
|
|||
|
64edf6656e
|
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
179
course_calendar/models.py
Normal file
179
course_calendar/models.py
Normal 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()
|
||||||
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>
|
<script>
|
||||||
function showModal(occurrenceId) {
|
function showModal(occurrenceId) {
|
||||||
// get event's url
|
// get event's url
|
||||||
const eventApiUrl = `/api/calendar/occurrences/${occurrenceId}`;
|
const eventApiUrl = `/calendar/api/calendar/occurrences/${occurrenceId}`;
|
||||||
|
|
||||||
const eventApi = fetch(eventApiUrl)
|
const eventApi = fetch(eventApiUrl)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
eventUrl = `/occurrence/${occurrenceId}/`;
|
eventUrl = `/calendar/occurrence/${occurrenceId}/`;
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.classList.add('fixed', 'inset-0', 'flex', 'items-center', 'justify-center', 'z-50', 'shadow-lg', 'overflow-auto');
|
modal.classList.add('fixed', 'inset-0', 'flex', 'items-center', 'justify-center', 'z-50', 'shadow-lg', 'overflow-auto');
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
right: 'timeGridWeek,listMonth',
|
right: 'timeGridWeek,listMonth',
|
||||||
},
|
},
|
||||||
locale: "{% get_current_language as LANGUAGE_CODE %}{{ LANGUAGE_CODE }}",
|
locale: "{% get_current_language as LANGUAGE_CODE %}{{ LANGUAGE_CODE }}",
|
||||||
events: "/api/calendar/occurrences/",
|
events: "/calendar/api/calendar/occurrences/",
|
||||||
eventClick: function(info) {
|
eventClick: function(info) {
|
||||||
// prevent default navigation
|
// prevent default navigation
|
||||||
info.jsEvent.preventDefault();
|
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"
|
||||||
|
)
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,11 +4,8 @@ from .pages import (
|
|||||||
CoursePage,
|
CoursePage,
|
||||||
CourseModulePage,
|
CourseModulePage,
|
||||||
ModuleLessonPage,
|
ModuleLessonPage,
|
||||||
EventPage,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from .event_occurrence import EventOccurrence
|
|
||||||
|
|
||||||
from .chat_message import ChatMessage
|
from .chat_message import ChatMessage
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +15,5 @@ __all__ = [
|
|||||||
"CoursePage",
|
"CoursePage",
|
||||||
"CourseModulePage",
|
"CourseModulePage",
|
||||||
"ModuleLessonPage",
|
"ModuleLessonPage",
|
||||||
"EventPage",
|
|
||||||
"EventOccurrence",
|
|
||||||
"ChatMessage",
|
"ChatMessage",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -1,21 +1,12 @@
|
|||||||
from datetime import datetime, timedelta
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import Group, User
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.forms import CheckboxSelectMultiple
|
from django.forms import CheckboxSelectMultiple
|
||||||
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 import blocks
|
||||||
from wagtail.admin.panels import FieldPanel
|
from wagtail.admin.panels import FieldPanel
|
||||||
from wagtail.fields import RichTextField, StreamField
|
from wagtail.fields import RichTextField, StreamField
|
||||||
from wagtail.images.blocks import ImageBlock
|
from wagtail.images.blocks import ImageBlock
|
||||||
from wagtail.models import Page
|
from wagtail.models import Page
|
||||||
from wagtail.models.copying import ParentalManyToManyField
|
from wagtail.models.copying import ParentalManyToManyField
|
||||||
from wagtail_color_panel.edit_handlers import NativeColorPanel
|
|
||||||
from wagtail_color_panel.fields import ColorField
|
|
||||||
|
|
||||||
from purchase.models import CoursePurchase
|
from purchase.models import CoursePurchase
|
||||||
|
|
||||||
@@ -226,167 +217,7 @@ class ModuleLessonPage(Page):
|
|||||||
FieldPanel(
|
FieldPanel(
|
||||||
"gitea_repo_url",
|
"gitea_repo_url",
|
||||||
read_only=True,
|
read_only=True,
|
||||||
heading="Gitea Repository URL (auto-generated if 'create_gitea_repo' is enabled)",
|
heading="Gitea Repository URL",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
parent_page_types = ["home.CourseModulePage"]
|
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.
|
|
||||||
"""
|
|
||||||
from .event_occurrence import EventOccurrence
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
if not self.recurrence_enabled:
|
|
||||||
# if recurrence is not enabled, ensure there's at least one occurrence for the specified start/end
|
|
||||||
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)
|
|
||||||
if self.live:
|
|
||||||
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"),
|
|
||||||
]
|
|
||||||
|
|||||||
109
home/signals.py
109
home/signals.py
@@ -6,7 +6,7 @@ from django.conf import settings
|
|||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from home.models.pages import CoursePage
|
from home.models.pages import CoursePage, ModuleLessonPage
|
||||||
|
|
||||||
GITEA_ORG_NAME = "Studio77"
|
GITEA_ORG_NAME = "Studio77"
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ logger = lg.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=CoursePage)
|
@receiver(post_save, sender=CoursePage)
|
||||||
def create_gitea_team_repo_on_course_creation(sender, instance, created, **kwargs):
|
def create_gitea_team_on_course_creation(sender, instance, created, **kwargs):
|
||||||
if not instance.live:
|
if not instance.live:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Course {instance.title} is not live, skipping Gitea team creation"
|
f"Course {instance.title} is not live, skipping Gitea team creation"
|
||||||
@@ -29,7 +29,6 @@ def create_gitea_team_repo_on_course_creation(sender, instance, created, **kwarg
|
|||||||
logger.debug("GITEA_URL is not set, skipping Gitea team creation")
|
logger.debug("GITEA_URL is not set, skipping Gitea team creation")
|
||||||
return
|
return
|
||||||
|
|
||||||
def team():
|
|
||||||
# check if team already exists
|
# check if team already exists
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@@ -84,7 +83,87 @@ def create_gitea_team_repo_on_course_creation(sender, instance, created, **kwarg
|
|||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
||||||
def repo():
|
# 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
|
# check if repository already exists
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@@ -94,9 +173,9 @@ def create_gitea_team_repo_on_course_creation(sender, instance, created, **kwarg
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
repos = response.json()
|
repos = response.json()
|
||||||
if any(repo["name"] == team_name for repo in repos):
|
if any(repo["name"] == repo_name for repo in repos):
|
||||||
logger.debug(
|
logger.info(
|
||||||
f"Gitea repository {team_name} already exists, skipping creation"
|
f"Gitea repository {repo_name} already exists, skipping creation"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -106,13 +185,12 @@ def create_gitea_team_repo_on_course_creation(sender, instance, created, **kwarg
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# create course repository
|
# create lesson repository
|
||||||
repo_name = f"course-{course.id}"
|
|
||||||
url = f"{api_url}/orgs/{GITEA_ORG_NAME}/repos"
|
url = f"{api_url}/orgs/{GITEA_ORG_NAME}/repos"
|
||||||
payload = {
|
payload = {
|
||||||
"auto_init": True,
|
"auto_init": True,
|
||||||
"default_branch": "main",
|
"default_branch": "main",
|
||||||
"description": f"{course.title}",
|
"description": f"{instance.module.course} – {instance.module}: {instance.title}",
|
||||||
"name": repo_name,
|
"name": repo_name,
|
||||||
"private": True,
|
"private": True,
|
||||||
}
|
}
|
||||||
@@ -126,16 +204,13 @@ def create_gitea_team_repo_on_course_creation(sender, instance, created, **kwarg
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
repo_url = response.json().get("url", None)
|
repo_url = response.json().get("url", None)
|
||||||
course.repository_url = repo_url
|
instance.gitea_repo_url = repo_url
|
||||||
course.save(update_fields=["repository_url"])
|
instance.save()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully created Gitea repository for course {course.title}"
|
f"Successfully created Gitea repository for lesson {instance.title}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Failed to create Gitea repository for course {course.title}: {e}\n{response.text}",
|
f"Failed to create Gitea repository for lesson {instance.title}: {e}\n{response.text}",
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
||||||
team()
|
|
||||||
repo()
|
|
||||||
|
|||||||
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
@@ -30,6 +30,7 @@ dotenv.load_dotenv(BASE_DIR / ".env")
|
|||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
"course_calendar",
|
||||||
"home",
|
"home",
|
||||||
"search",
|
"search",
|
||||||
"purchase",
|
"purchase",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<nav class="flex items-center gap-4">
|
<nav class="flex items-center gap-4">
|
||||||
<a class="text-xl font-bold" href="/">{{ current_site.site_name }}</a>
|
<a class="text-xl font-bold" href="/">{{ current_site.site_name }}</a>
|
||||||
<a href="{% slugurl 'courses' %}" class="hover:underline">{% trans "Courses" %}</a>
|
<a href="{% 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>
|
||||||
|
|
||||||
<nav class="flex items-center gap-4">
|
<nav class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -23,33 +23,8 @@ urlpatterns = [
|
|||||||
path("oauth2/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
path("oauth2/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||||
path("", include("home.urls")),
|
path("", include("home.urls")),
|
||||||
path("", include("purchase.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
|
# 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",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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.http import JsonResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
from home.models import EventOccurrence, EventPage
|
# from home.models import EventOccurrence, EventPage
|
||||||
|
|
||||||
from .forms import SignUpForm
|
from .forms import SignUpForm
|
||||||
|
|
||||||
@@ -23,102 +23,3 @@ def signup(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def profile(request):
|
def profile(request):
|
||||||
return render(request, "profile.html", {"user": request.user})
|
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}")
|
|
||||||
|
|||||||
Reference in New Issue
Block a user