From 3e383f1dee76ba8d2b1d7fc2d9398ed9bb06cbd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 07:50:45 +0000 Subject: [PATCH 1/2] Initial plan From f266d809bc4e3a40318eb296884e4a8b8dccce82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 08:05:35 +0000 Subject: [PATCH 2/2] Add generic Gathering feature: models, forms, views, URLs, templates, migration, and tests Co-authored-by: A1L13N <193832434+A1L13N@users.noreply.github.com> --- web/admin.py | 47 +++ web/forms.py | 99 +++++++ ...eringregistration_gatheringannouncement.py | 189 ++++++++++++ web/models.py | 175 +++++++++++ web/templates/gatherings/create.html | 257 ++++++++++++++++ web/templates/gatherings/detail.html | 278 ++++++++++++++++++ web/templates/gatherings/list.html | 196 ++++++++++++ web/templates/gatherings/manage.html | 143 +++++++++ web/templates/gatherings/my_gatherings.html | 110 +++++++ web/tests/test_gatherings.py | 278 ++++++++++++++++++ web/urls.py | 23 ++ web/views.py | 209 +++++++++++++ 12 files changed, 2004 insertions(+) create mode 100644 web/migrations/0064_gathering_gatheringregistration_gatheringannouncement.py create mode 100644 web/templates/gatherings/create.html create mode 100644 web/templates/gatherings/detail.html create mode 100644 web/templates/gatherings/list.html create mode 100644 web/templates/gatherings/manage.html create mode 100644 web/templates/gatherings/my_gatherings.html create mode 100644 web/tests/test_gatherings.py diff --git a/web/admin.py b/web/admin.py index a0eb4328f..bb18ee820 100644 --- a/web/admin.py +++ b/web/admin.py @@ -26,6 +26,9 @@ ForumCategory, ForumReply, ForumTopic, + Gathering, + GatheringAnnouncement, + GatheringRegistration, Goods, LearningStreak, MembershipPlan, @@ -889,3 +892,47 @@ class VideoRequestAdmin(admin.ModelAdmin): list_display = ("title", "status", "category", "requester", "created_at") list_filter = ("status", "category") search_fields = ("title", "description", "requester__username") + + +@admin.register(Gathering) +class GatheringAdmin(admin.ModelAdmin): + list_display = ("title", "gathering_type", "organizer", "start_datetime", "status", "visibility", "attendee_count") + list_filter = ("gathering_type", "status", "visibility", "is_virtual") + search_fields = ("title", "description", "organizer__username", "organizer__email") + prepopulated_fields = {"slug": ("title",)} + raw_id_fields = ("organizer",) + readonly_fields = ("created_at", "updated_at") + date_hierarchy = "start_datetime" + fieldsets = ( + (None, {"fields": ("title", "slug", "description", "gathering_type", "organizer", "image", "tags")}), + ("Schedule", {"fields": ("start_datetime", "end_datetime")}), + ("Location", {"fields": ("is_virtual", "meeting_link", "location", "latitude", "longitude")}), + ( + "Registration", + {"fields": ("registration_required", "max_attendees", "price")}, + ), + ("Visibility", {"fields": ("status", "visibility")}), + ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), + ) + + def attendee_count(self, obj): + return obj.attendee_count + + attendee_count.short_description = "Attendees" + + +@admin.register(GatheringRegistration) +class GatheringRegistrationAdmin(admin.ModelAdmin): + list_display = ("gathering", "attendee", "status", "registered_at") + list_filter = ("status",) + search_fields = ("gathering__title", "attendee__username", "attendee__email") + raw_id_fields = ("gathering", "attendee") + readonly_fields = ("registered_at", "updated_at") + + +@admin.register(GatheringAnnouncement) +class GatheringAnnouncementAdmin(admin.ModelAdmin): + list_display = ("gathering", "title", "author", "created_at") + search_fields = ("gathering__title", "title", "author__username") + raw_id_fields = ("gathering", "author") + readonly_fields = ("created_at", "updated_at") diff --git a/web/forms.py b/web/forms.py index 96489d524..50709dbdd 100644 --- a/web/forms.py +++ b/web/forms.py @@ -29,6 +29,9 @@ CourseMaterial, EducationalVideo, ForumCategory, + Gathering, + GatheringAnnouncement, + GatheringRegistration, Goods, GradeableLink, LinkGrade, @@ -112,6 +115,9 @@ "SurveyForm", "VirtualClassroomForm", "VirtualClassroomCustomizationForm", + "GatheringForm", + "GatheringRegistrationForm", + "GatheringAnnouncementForm", ] fernet = Fernet(settings.SECURE_MESSAGE_KEY) @@ -2084,3 +2090,96 @@ def clean_desks_per_row(self): if value is None or value < 1 or value > 8: raise forms.ValidationError("Desks per row must be between 1 and 8.") return value + + +class GatheringForm(forms.ModelForm): + """Form for creating and editing a Gathering (event, meetup, class, etc.).""" + + class Meta: + model = Gathering + fields = [ + "title", + "description", + "gathering_type", + "start_datetime", + "end_datetime", + "is_virtual", + "meeting_link", + "location", + "max_attendees", + "registration_required", + "price", + "visibility", + "status", + "image", + "tags", + ] + widgets = { + "title": TailwindInput(), + "description": TailwindTextarea(attrs={"rows": 5}), + "gathering_type": TailwindSelect(), + "start_datetime": TailwindDateTimeInput(), + "end_datetime": TailwindDateTimeInput(), + "meeting_link": URLInput( + attrs={ + "class": ( + "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 " + "rounded-lg focus:ring-2 focus:ring-teal-400 " + "bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" + ), + "placeholder": "https://meet.example.com/...", + } + ), + "location": TailwindInput(attrs={"placeholder": "Address or venue name"}), + "max_attendees": TailwindNumberInput(attrs={"min": "1", "placeholder": "Leave blank for unlimited"}), + "price": TailwindNumberInput(attrs={"min": "0", "step": "0.01"}), + "visibility": TailwindSelect(), + "status": TailwindSelect(), + "tags": TailwindInput(attrs={"placeholder": "python, django, web (comma separated)"}), + "is_virtual": TailwindCheckboxInput(), + "registration_required": TailwindCheckboxInput(), + } + + def clean(self): + cleaned_data = super().clean() + start = cleaned_data.get("start_datetime") + end = cleaned_data.get("end_datetime") + is_virtual = cleaned_data.get("is_virtual") + meeting_link = cleaned_data.get("meeting_link") + location = cleaned_data.get("location") + + if start and end and end <= start: + raise forms.ValidationError("End date/time must be after the start date/time.") + + if is_virtual and not meeting_link: + self.add_error("meeting_link", "A meeting link is required for virtual gatherings.") + + if not is_virtual and not location: + self.add_error("location", "A location is required for in-person gatherings.") + + return cleaned_data + + +class GatheringRegistrationForm(forms.ModelForm): + """Form for attendees to register for a Gathering.""" + + class Meta: + model = GatheringRegistration + fields = ["notes"] + widgets = { + "notes": TailwindTextarea( + attrs={"rows": 3, "placeholder": "Any notes or questions for the organizer (optional)"} + ), + } + + +class GatheringAnnouncementForm(forms.ModelForm): + """Form for organizers to post announcements to gathering attendees.""" + + class Meta: + model = GatheringAnnouncement + fields = ["title", "content"] + widgets = { + "title": TailwindInput(attrs={"placeholder": "Announcement title"}), + "content": TailwindTextarea(attrs={"rows": 4, "placeholder": "Write your announcement here..."}), + } diff --git a/web/migrations/0064_gathering_gatheringregistration_gatheringannouncement.py b/web/migrations/0064_gathering_gatheringregistration_gatheringannouncement.py new file mode 100644 index 000000000..0ca35fb63 --- /dev/null +++ b/web/migrations/0064_gathering_gatheringregistration_gatheringannouncement.py @@ -0,0 +1,189 @@ +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0063_virtualclassroom_virtualclassroomcustomization_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Gathering", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=255)), + ("slug", models.SlugField(blank=True, max_length=255, unique=True)), + ("description", models.TextField()), + ( + "gathering_type", + models.CharField( + choices=[ + ("meetup", "Meetup"), + ("workshop", "Workshop"), + ("class", "Class"), + ("webinar", "Webinar"), + ("conference", "Conference"), + ("study_group", "Study Group"), + ("networking", "Networking"), + ("hackathon", "Hackathon"), + ("club_meeting", "Club Meeting"), + ("other", "Other"), + ], + default="meetup", + max_length=50, + ), + ), + ("start_datetime", models.DateTimeField()), + ("end_datetime", models.DateTimeField()), + ("is_virtual", models.BooleanField(default=False)), + ("meeting_link", models.URLField(blank=True)), + ("location", models.CharField(blank=True, max_length=255)), + ( + "latitude", + models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ( + "longitude", + models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ( + "max_attendees", + models.PositiveIntegerField(blank=True, help_text="Leave blank for unlimited", null=True), + ), + ("registration_required", models.BooleanField(default=True)), + ( + "price", + models.DecimalField(decimal_places=2, default=0.0, max_digits=10), + ), + ( + "visibility", + models.CharField( + choices=[ + ("public", "Public"), + ("private", "Private"), + ("invite_only", "Invite Only"), + ], + default="public", + max_length=20, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("published", "Published"), + ("cancelled", "Cancelled"), + ("completed", "Completed"), + ], + default="draft", + max_length=20, + ), + ), + ("image", models.ImageField(blank=True, upload_to="gatherings/")), + ( + "tags", + models.CharField(blank=True, help_text="Comma-separated tags", max_length=255), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "organizer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="organized_gatherings", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Gathering", + "verbose_name_plural": "Gatherings", + "ordering": ["start_datetime"], + }, + ), + migrations.CreateModel( + name="GatheringRegistration", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("confirmed", "Confirmed"), + ("cancelled", "Cancelled"), + ("waitlisted", "Waitlisted"), + ("attended", "Attended"), + ], + default="pending", + max_length=20, + ), + ), + ( + "notes", + models.TextField(blank=True, help_text="Optional notes from the attendee"), + ), + ("registered_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "attendee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="gathering_registrations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "gathering", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="registrations", + to="web.gathering", + ), + ), + ], + options={ + "verbose_name": "Gathering Registration", + "verbose_name_plural": "Gathering Registrations", + "ordering": ["registered_at"], + "unique_together": {("gathering", "attendee")}, + }, + ), + migrations.CreateModel( + name="GatheringAnnouncement", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=255)), + ("content", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="gathering_announcements", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "gathering", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="announcements", + to="web.gathering", + ), + ), + ], + options={ + "verbose_name": "Gathering Announcement", + "verbose_name_plural": "Gathering Announcements", + "ordering": ["-created_at"], + }, + ), + ] diff --git a/web/models.py b/web/models.py index 6b8ea8ef4..7fef4ef78 100644 --- a/web/models.py +++ b/web/models.py @@ -3176,3 +3176,178 @@ class Meta: ordering = ["-last_updated"] verbose_name = "Virtual Classroom Whiteboard" verbose_name_plural = "Virtual Classroom Whiteboards" + + +class Gathering(models.Model): + """Generic model for any type of event, meetup, class, or gathering. + + Designed to be flexible enough to handle in-person meetups, online webinars, + workshops, club meetings, classes, or any other type of gathering. + """ + + GATHERING_TYPE_CHOICES = [ + ("meetup", "Meetup"), + ("workshop", "Workshop"), + ("class", "Class"), + ("webinar", "Webinar"), + ("conference", "Conference"), + ("study_group", "Study Group"), + ("networking", "Networking"), + ("hackathon", "Hackathon"), + ("club_meeting", "Club Meeting"), + ("other", "Other"), + ] + + STATUS_CHOICES = [ + ("draft", "Draft"), + ("published", "Published"), + ("cancelled", "Cancelled"), + ("completed", "Completed"), + ] + + VISIBILITY_CHOICES = [ + ("public", "Public"), + ("private", "Private"), + ("invite_only", "Invite Only"), + ] + + title = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True, blank=True) + description = models.TextField() + gathering_type = models.CharField(max_length=50, choices=GATHERING_TYPE_CHOICES, default="meetup") + organizer = models.ForeignKey(User, on_delete=models.CASCADE, related_name="organized_gatherings") + + start_datetime = models.DateTimeField() + end_datetime = models.DateTimeField() + + is_virtual = models.BooleanField(default=False) + meeting_link = models.URLField(blank=True) + location = models.CharField(max_length=255, blank=True) + latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + + max_attendees = models.PositiveIntegerField(null=True, blank=True, help_text="Leave blank for unlimited") + registration_required = models.BooleanField(default=True) + price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00) + + visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default="public") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="draft") + + image = models.ImageField(upload_to="gatherings/", blank=True) + tags = models.CharField(max_length=255, blank=True, help_text="Comma-separated tags") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["start_datetime"] + verbose_name = "Gathering" + verbose_name_plural = "Gatherings" + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title) + slug = base_slug + counter = 1 + while Gathering.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + def get_absolute_url(self): + from django.urls import reverse + + return reverse("gathering_detail", kwargs={"slug": self.slug}) + + @property + def is_full(self): + """Return True if the gathering has reached max attendees.""" + if self.max_attendees is None: + return False + return self.registrations.filter(status="confirmed").count() >= self.max_attendees + + @property + def attendee_count(self): + """Return the number of confirmed attendees.""" + return self.registrations.filter(status="confirmed").count() + + @property + def is_free(self): + """Return True if the gathering is free.""" + return self.price == 0 + + @property + def spots_remaining(self): + """Return the number of spots remaining, or None if unlimited.""" + if self.max_attendees is None: + return None + return max(0, self.max_attendees - self.attendee_count) + + @property + def is_upcoming(self): + """Return True if the gathering has not yet started.""" + return self.start_datetime > timezone.now() + + @property + def is_past(self): + """Return True if the gathering has ended.""" + return self.end_datetime < timezone.now() + + @property + def tag_list(self): + """Return a list of tags.""" + return [t.strip() for t in self.tags.split(",") if t.strip()] + + +class GatheringRegistration(models.Model): + """Tracks attendee registrations for a Gathering. + + Supports pending, confirmed, cancelled, and waitlisted states. + """ + + STATUS_CHOICES = [ + ("pending", "Pending"), + ("confirmed", "Confirmed"), + ("cancelled", "Cancelled"), + ("waitlisted", "Waitlisted"), + ("attended", "Attended"), + ] + + gathering = models.ForeignKey(Gathering, on_delete=models.CASCADE, related_name="registrations") + attendee = models.ForeignKey(User, on_delete=models.CASCADE, related_name="gathering_registrations") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") + notes = models.TextField(blank=True, help_text="Optional notes from the attendee") + registered_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("gathering", "attendee") + ordering = ["registered_at"] + verbose_name = "Gathering Registration" + verbose_name_plural = "Gathering Registrations" + + def __str__(self): + return f"{self.attendee.username} - {self.gathering.title} ({self.status})" + + +class GatheringAnnouncement(models.Model): + """Announcements posted by organizers to gathering attendees.""" + + gathering = models.ForeignKey(Gathering, on_delete=models.CASCADE, related_name="announcements") + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="gathering_announcements") + title = models.CharField(max_length=255) + content = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + verbose_name = "Gathering Announcement" + verbose_name_plural = "Gathering Announcements" + + def __str__(self): + return f"{self.gathering.title} — {self.title}" diff --git a/web/templates/gatherings/create.html b/web/templates/gatherings/create.html new file mode 100644 index 000000000..80d32466b --- /dev/null +++ b/web/templates/gatherings/create.html @@ -0,0 +1,257 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block title %} + {% if editing %} + {% trans "Edit Gathering" %} + {% else %} + {% trans "Create Gathering" %} + {% endif %} +{% endblock title %} +{% block content %} +
+
+ +

+ + {% if editing %} + {% trans "Edit Gathering" %} + {% else %} + {% trans "Create a Gathering" %} + {% endif %} +

+

+ {% if editing %} + {% trans "Update the details of your gathering." %} + {% else %} + {% trans "Organize a meetup, class, workshop, webinar, or any other gathering." %} + {% endif %} +

+
+
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %}

{{ error }}

{% endfor %} +
+ {% endif %} + {# Basic info #} +
+ + {% trans "Basic Information" %} + +
+ + {{ form.title }} + {% if form.title.errors %} +

{{ form.title.errors.0 }}

+ {% endif %} +
+
+ + {{ form.gathering_type }} + {% if form.gathering_type.errors %} +

{{ form.gathering_type.errors.0 }}

+ {% endif %} +
+
+ + {{ form.description }} + {% if form.description.errors %} +

{{ form.description.errors.0 }}

+ {% endif %} +
+
+ + {{ form.tags }} +

{{ form.tags.help_text }}

+ {% if form.tags.errors %} +

{{ form.tags.errors.0 }}

+ {% endif %} +
+
+ + {{ form.image }} + {% if form.image.errors %} +

{{ form.image.errors.0 }}

+ {% endif %} +
+
+ {# Date & time #} +
+ + {% trans "Date & Time" %} + +
+
+ + {{ form.start_datetime }} + {% if form.start_datetime.errors %} +

{{ form.start_datetime.errors.0 }}

+ {% endif %} +
+
+ + {{ form.end_datetime }} + {% if form.end_datetime.errors %} +

{{ form.end_datetime.errors.0 }}

+ {% endif %} +
+
+
+ {# Location #} +
+ + {% trans "Location & Format" %} + +
+ {{ form.is_virtual }} + +
+
+ + {{ form.meeting_link }} + {% if form.meeting_link.errors %} +

{{ form.meeting_link.errors.0 }}

+ {% endif %} +
+
+ + {{ form.location }} + {% if form.location.errors %} +

{{ form.location.errors.0 }}

+ {% endif %} +
+
+ {# Registration & capacity #} +
+ + {% trans "Registration & Capacity" %} + +
+ {{ form.registration_required }} + +
+
+
+ + {{ form.max_attendees }} +

{{ form.max_attendees.help_text }}

+ {% if form.max_attendees.errors %} +

{{ form.max_attendees.errors.0 }}

+ {% endif %} +
+
+ + {{ form.price }} + {% if form.price.errors %} +

{{ form.price.errors.0 }}

+ {% endif %} +
+
+
+ {# Visibility & status #} +
+ + {% trans "Visibility & Status" %} + +
+
+ + {{ form.visibility }} + {% if form.visibility.errors %} +

{{ form.visibility.errors.0 }}

+ {% endif %} +
+
+ + {{ form.status }} + {% if form.status.errors %} +

{{ form.status.errors.0 }}

+ {% endif %} +
+
+
+ {# Submit #} +
+ + + {% trans "Cancel" %} + +
+
+
+
+{% endblock content %} diff --git a/web/templates/gatherings/detail.html b/web/templates/gatherings/detail.html new file mode 100644 index 000000000..86b37acc4 --- /dev/null +++ b/web/templates/gatherings/detail.html @@ -0,0 +1,278 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block title %} + {{ gathering.title }} +{% endblock title %} +{% block content %} +
+ {# Breadcrumb #} + +
+ {# Main content #} +
+ {# Header #} +
+ {% if gathering.image %} + {{ gathering.title }} + {% else %} +
+ +
+ {% endif %} +
+
+ + {{ gathering.get_gathering_type_display }} + + {% if gathering.is_virtual %} + + {% trans "Virtual" %} + + {% else %} + + {% trans "In-Person" %} + + {% endif %} + {% if gathering.is_free %} + + {% trans "Free" %} + + {% endif %} + + {{ gathering.get_status_display }} + +
+

{{ gathering.title }}

+
+ {{ gathering.description|linebreaks }} +
+ {% if gathering.tag_list %} +
+ {% for tag in gathering.tag_list %} + + #{{ tag }} + + {% endfor %} +
+ {% endif %} +
+
+ {# Announcements #} + {% if announcements %} +
+

+ {% trans "Announcements" %} +

+
+ {% for ann in announcements %} +
+

{{ ann.title }}

+

{{ ann.content|linebreaks }}

+

{{ ann.created_at|date:"M j, Y g:i A" }}

+
+ {% endfor %} +
+
+ {% endif %} + {# Organizer actions #} + {% if user == gathering.organizer %} +
+ + {% trans "You are the organizer" %} + + + {% trans "Edit" %} + + + {% trans "Manage Attendees" %} + +
+ {% csrf_token %} + +
+
+ {% endif %} +
+ {# Sidebar #} +
+ {# Date & Time #} +
+

+ {% trans "When & Where" %} +

+
+
+ +
+
{{ gathering.start_datetime|date:"l, F j, Y" }}
+
+ {{ gathering.start_datetime|time:"g:i A" }} + {% if gathering.end_datetime %}– {{ gathering.end_datetime|time:"g:i A" }}{% endif %} +
+
+
+ {% if gathering.is_virtual %} +
+ +
+
{% trans "Virtual Event" %}
+ {% if user_registration and user_registration.status == "confirmed" and gathering.meeting_link %} + + {% trans "Join Meeting" %} + + {% else %} + {% trans "Link available after confirmation" %} + {% endif %} +
+
+ {% elif gathering.location %} +
+ +
+
{% trans "In Person" %}
+
{{ gathering.location }}
+
+
+ {% endif %} +
+
+ {# Organizer #} +
+

+ {% trans "Organizer" %} +

+
+
+ {{ gathering.organizer.username|first|upper }} +
+
+
+ {{ gathering.organizer.get_full_name|default:gathering.organizer.username }} +
+ {% trans "View Profile" %} +
+
+
+ {# Attendance & Registration #} +
+

+ {% trans "Attendance" %} +

+
+ +
+ {{ gathering.attendee_count }} + {% if gathering.max_attendees %} + / {{ gathering.max_attendees }} + {% endif %} +
{% trans "confirmed" %}
+
+
+ {% if gathering.max_attendees %} +
+
+
+ {% if gathering.spots_remaining == 0 %} +

{% trans "This gathering is full." %}

+ {% elif gathering.spots_remaining is not None %} +

+ {{ gathering.spots_remaining }} {% trans "spot(s) remaining" %} +

+ {% endif %} + {% endif %} + {# Registration button #} + {% if not gathering.registration_required %} +

{% trans "No registration required — just show up!" %}

+ {% elif user.is_authenticated %} + {% if user == gathering.organizer %} +

{% trans "You are the organizer of this gathering." %}

+ {% elif user_registration %} +
+ + {% if user_registration.status == 'confirmed' %} + {% trans "You are registered!" %} + {% elif user_registration.status == 'waitlisted' %} + {% trans "You are on the waitlist." %} + {% elif user_registration.status == 'cancelled' %} + {% trans "Registration cancelled." %} + {% else %} + {% trans "Awaiting confirmation." %} + {% endif %} +
+ {% if user_registration.status != 'cancelled' %} +
+ {% csrf_token %} + +
+ {% endif %} + {% elif gathering.status == 'published' %} +
+ {% csrf_token %} + + +
+ {% endif %} + {% else %} + + {% trans "Sign in to Register" %} + + {% endif %} +
+ {# Price #} + {% if not gathering.is_free %} +
+

+ {% trans "Price" %} +

+
${{ gathering.price }}
+
+ {% endif %} +
+
+
+{% endblock content %} diff --git a/web/templates/gatherings/list.html b/web/templates/gatherings/list.html new file mode 100644 index 000000000..ba90190c3 --- /dev/null +++ b/web/templates/gatherings/list.html @@ -0,0 +1,196 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block title %} + {% trans "Gatherings" %} +{% endblock title %} +{% block content %} +
+
+
+

{% trans "Gatherings" %}

+

+ {% trans "Browse meetups, workshops, classes, webinars and more." %} +

+
+ {% if user.is_authenticated %} + + {% endif %} +
+ {# Filters #} +
+
+ + +
+
+ + +
+
+ + +
+ + {% if query or gathering_type or is_virtual %} + + {% trans "Clear" %} + + {% endif %} +
+ {% if page_obj %} +
+ {% for gathering in page_obj %} +
+ {% if gathering.image %} + {{ gathering.title }} + {% else %} +
+ +
+ {% endif %} +
+
+ + {{ gathering.get_gathering_type_display }} + + {% if gathering.is_virtual %} + + {% trans "Virtual" %} + + {% else %} + + {% trans "In-Person" %} + + {% endif %} + {% if gathering.is_free %} + {% trans "Free" %} + {% endif %} +
+

+ {{ gathering.title }} +

+

+ {{ gathering.description|truncatewords:20 }} +

+
+
+ + {{ gathering.start_datetime|date:"M j, Y" }} + · + + {{ gathering.start_datetime|time:"g:i A" }} +
+ {% if not gathering.is_virtual and gathering.location %} +
+ {{ gathering.location }} +
+ {% endif %} +
+ + {{ gathering.organizer.get_full_name|default:gathering.organizer.username }} +
+
+
+
+ + {{ gathering.attendee_count }} + {% if gathering.max_attendees %}/ {{ gathering.max_attendees }}{% endif %} + {% trans "attending" %} +
+ + {% trans "View Details" %} + +
+
+
+ {% endfor %} +
+ {# Pagination #} + {% if page_obj.has_other_pages %} + + {% endif %} + {% else %} +
+ +

{% trans "No gatherings found" %}

+

{% trans "Be the first to create one!" %}

+ {% if user.is_authenticated %} + + {% trans "Create Gathering" %} + + {% endif %} +
+ {% endif %} +
+{% endblock content %} diff --git a/web/templates/gatherings/manage.html b/web/templates/gatherings/manage.html new file mode 100644 index 000000000..aed5fe48d --- /dev/null +++ b/web/templates/gatherings/manage.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block title %} + {% trans "Manage" %} — {{ gathering.title }} +{% endblock title %} +{% block content %} +
+ +
+

+ + {% trans "Manage Gathering" %} +

+ + {% trans "Back to Gathering" %} + +
+
+ {# Registrations #} +
+

+ {% trans "Registrations" %} + ({{ registrations.count }}) +

+ {% if registrations %} +
+ {% for reg in registrations %} +
+
+
+
+ {{ reg.attendee.username|first|upper }} +
+
+
+ {{ reg.attendee.get_full_name|default:reg.attendee.username }} +
+
+ {% trans "Registered" %} {{ reg.registered_at|date:"M j, Y" }} +
+ {% if reg.notes %} +
"{{ reg.notes|truncatewords:15 }}"
+ {% endif %} +
+
+ + {{ reg.get_status_display }} + +
+ {# Status update form #} +
+ {% csrf_token %} + + + + +
+
+ {% endfor %} +
+ {% else %} +
+ +

{% trans "No registrations yet." %}

+
+ {% endif %} +
+ {# Announcements #} +
+

{% trans "Post Announcement" %}

+
+
+ {% csrf_token %} + + {% if announcement_form.non_field_errors %} +
+ {% for error in announcement_form.non_field_errors %}

{{ error }}

{% endfor %} +
+ {% endif %} +
+ + {{ announcement_form.title }} + {% if announcement_form.title.errors %} +

{{ announcement_form.title.errors.0 }}

+ {% endif %} +
+
+ + {{ announcement_form.content }} + {% if announcement_form.content.errors %} +

{{ announcement_form.content.errors.0 }}

+ {% endif %} +
+ +
+
+ {% if announcements %} +

+ {% trans "Previous Announcements" %} +

+
+ {% for ann in announcements %} +
+
{{ ann.title }}
+
{{ ann.content|linebreaks }}
+
{{ ann.created_at|date:"M j, Y g:i A" }}
+
+ {% endfor %} +
+ {% endif %} +
+
+
+{% endblock content %} diff --git a/web/templates/gatherings/my_gatherings.html b/web/templates/gatherings/my_gatherings.html new file mode 100644 index 000000000..ea5cee391 --- /dev/null +++ b/web/templates/gatherings/my_gatherings.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block title %} + {% trans "My Gatherings" %} +{% endblock title %} +{% block content %} +
+
+
+

+ {% trans "My Gatherings" %} +

+

{% trans "Gatherings you have organized." %}

+
+ + {% trans "Create Gathering" %} + +
+ {% if page_obj %} +
+ {% for gathering in page_obj %} +
+
+
+ + {{ gathering.get_gathering_type_display }} + + + {{ gathering.get_status_display }} + + {% if gathering.is_virtual %} + + {% trans "Virtual" %} + + {% endif %} +
+

+ {{ gathering.title }} +

+
+ + {{ gathering.start_datetime|date:"M j, Y g:i A" }} + · + + {{ gathering.attendee_count }} + {% if gathering.max_attendees %}/ {{ gathering.max_attendees }}{% endif %} + {% trans "attending" %} +
+
+ +
+ {% endfor %} +
+ {# Pagination #} + {% if page_obj.has_other_pages %} + + {% endif %} + {% else %} +
+ +

{% trans "No gatherings yet" %}

+

{% trans "Start by creating your first gathering." %}

+ + {% trans "Create Gathering" %} + +
+ {% endif %} +
+{% endblock content %} diff --git a/web/tests/test_gatherings.py b/web/tests/test_gatherings.py new file mode 100644 index 000000000..6204d7947 --- /dev/null +++ b/web/tests/test_gatherings.py @@ -0,0 +1,278 @@ +from django.contrib.auth.models import User +from django.test import Client, TestCase, override_settings +from django.urls import reverse +from django.utils import timezone + +from web.models import Gathering, GatheringAnnouncement, GatheringRegistration, Profile + + +def _make_datetime(days_from_now): + return timezone.now() + timezone.timedelta(days=days_from_now) + + +@override_settings(STRIPE_SECRET_KEY="dummy_key") +class GatheringModelTests(TestCase): + """Unit tests for the Gathering, GatheringRegistration, and GatheringAnnouncement models.""" + + @classmethod + def setUpTestData(cls): + cls.organizer = User.objects.create_user(username="organizer", email="organizer@example.com", password="pass") + Profile.objects.get_or_create(user=cls.organizer) + + cls.attendee = User.objects.create_user(username="attendee", email="attendee@example.com", password="pass") + Profile.objects.get_or_create(user=cls.attendee) + + def _create_gathering(self, **kwargs): + defaults = { + "title": "Test Gathering", + "description": "A test gathering", + "gathering_type": "meetup", + "organizer": self.organizer, + "start_datetime": _make_datetime(1), + "end_datetime": _make_datetime(2), + "is_virtual": False, + "location": "Test Hall", + "status": "published", + "visibility": "public", + } + defaults.update(kwargs) + return Gathering.objects.create(**defaults) + + def test_slug_auto_generated(self): + """Slug is automatically created from the title.""" + g = self._create_gathering(title="My Cool Meetup") + self.assertEqual(g.slug, "my-cool-meetup") + + def test_slug_unique_suffix(self): + """Duplicate titles get a numbered suffix to keep slugs unique.""" + g1 = self._create_gathering(title="Python Night") + g2 = self._create_gathering(title="Python Night") + self.assertNotEqual(g1.slug, g2.slug) + self.assertTrue(g2.slug.startswith("python-night-")) + + def test_str_representation(self): + g = self._create_gathering(title="Hackathon Day") + self.assertEqual(str(g), "Hackathon Day") + + def test_is_free_when_price_zero(self): + g = self._create_gathering(price=0) + self.assertTrue(g.is_free) + + def test_is_not_free_when_price_positive(self): + g = self._create_gathering(price=10) + self.assertFalse(g.is_free) + + def test_is_upcoming_for_future_event(self): + g = self._create_gathering( + start_datetime=_make_datetime(3), + end_datetime=_make_datetime(4), + ) + self.assertTrue(g.is_upcoming) + + def test_is_past_for_past_event(self): + g = self._create_gathering( + start_datetime=_make_datetime(-3), + end_datetime=_make_datetime(-1), + ) + self.assertTrue(g.is_past) + + def test_attendee_count_only_confirmed(self): + g = self._create_gathering() + GatheringRegistration.objects.create(gathering=g, attendee=self.attendee, status="confirmed") + self.assertEqual(g.attendee_count, 1) + + def test_attendee_count_excludes_pending(self): + g = self._create_gathering() + GatheringRegistration.objects.create(gathering=g, attendee=self.attendee, status="pending") + self.assertEqual(g.attendee_count, 0) + + def test_is_full_when_max_reached(self): + g = self._create_gathering(max_attendees=1) + GatheringRegistration.objects.create(gathering=g, attendee=self.attendee, status="confirmed") + self.assertTrue(g.is_full) + + def test_is_not_full_when_below_max(self): + g = self._create_gathering(max_attendees=5) + self.assertFalse(g.is_full) + + def test_spots_remaining(self): + g = self._create_gathering(max_attendees=3) + GatheringRegistration.objects.create(gathering=g, attendee=self.attendee, status="confirmed") + self.assertEqual(g.spots_remaining, 2) + + def test_spots_remaining_none_when_unlimited(self): + g = self._create_gathering(max_attendees=None) + self.assertIsNone(g.spots_remaining) + + def test_tag_list_parsed_correctly(self): + g = self._create_gathering(tags="python, django, web") + self.assertEqual(g.tag_list, ["python", "django", "web"]) + + def test_tag_list_empty_for_no_tags(self): + g = self._create_gathering(tags="") + self.assertEqual(g.tag_list, []) + + def test_gathering_registration_str(self): + g = self._create_gathering() + reg = GatheringRegistration.objects.create(gathering=g, attendee=self.attendee, status="confirmed") + self.assertIn(self.attendee.username, str(reg)) + self.assertIn(g.title, str(reg)) + + def test_gathering_announcement_str(self): + g = self._create_gathering() + ann = GatheringAnnouncement.objects.create( + gathering=g, author=self.organizer, title="Update", content="See you there!" + ) + self.assertIn(g.title, str(ann)) + self.assertIn("Update", str(ann)) + + +@override_settings(STRIPE_SECRET_KEY="dummy_key") +class GatheringViewTests(TestCase): + """Integration tests for gathering views.""" + + @classmethod + def setUpTestData(cls): + cls.organizer = User.objects.create_user(username="org_user", email="org@example.com", password="pass123") + Profile.objects.get_or_create(user=cls.organizer) + + cls.other_user = User.objects.create_user(username="other_user", email="other@example.com", password="pass123") + Profile.objects.get_or_create(user=cls.other_user) + + def setUp(self): + self.client = Client() + self.gathering = Gathering.objects.create( + title="Public Meetup", + description="Open to all", + gathering_type="meetup", + organizer=self.organizer, + start_datetime=_make_datetime(5), + end_datetime=_make_datetime(6), + is_virtual=False, + location="Community Centre", + status="published", + visibility="public", + ) + + def test_list_view_returns_200(self): + response = self.client.get(reverse("gathering_list")) + self.assertEqual(response.status_code, 200) + + def test_list_view_shows_published_gathering(self): + response = self.client.get(reverse("gathering_list")) + self.assertContains(response, "Public Meetup") + + def test_detail_view_returns_200(self): + response = self.client.get(reverse("gathering_detail", kwargs={"slug": self.gathering.slug})) + self.assertEqual(response.status_code, 200) + + def test_detail_view_shows_title(self): + response = self.client.get(reverse("gathering_detail", kwargs={"slug": self.gathering.slug})) + self.assertContains(response, "Public Meetup") + + def test_create_view_requires_login(self): + response = self.client.get(reverse("create_gathering")) + self.assertRedirects( + response, f"/accounts/login/?next={reverse('create_gathering')}", fetch_redirect_response=False + ) + + def test_create_gathering_logged_in(self): + self.client.login(username="org_user", password="pass123") + response = self.client.get(reverse("create_gathering")) + self.assertEqual(response.status_code, 200) + + def test_create_gathering_post(self): + self.client.login(username="org_user", password="pass123") + data = { + "title": "New Workshop", + "description": "A great workshop", + "gathering_type": "workshop", + "start_datetime": (_make_datetime(10)).strftime("%Y-%m-%dT%H:%M"), + "end_datetime": (_make_datetime(11)).strftime("%Y-%m-%dT%H:%M"), + "is_virtual": False, + "location": "Room 101", + "registration_required": True, + "price": "0.00", + "visibility": "public", + "status": "published", + "max_attendees": "", + "meeting_link": "", + "tags": "", + } + response = self.client.post(reverse("create_gathering"), data) + # Should redirect after creation + self.assertEqual(response.status_code, 302) + self.assertTrue(Gathering.objects.filter(title="New Workshop").exists()) + + def test_edit_gathering_by_non_organizer_redirects(self): + self.client.login(username="other_user", password="pass123") + response = self.client.get(reverse("edit_gathering", kwargs={"slug": self.gathering.slug})) + # Should redirect with an error + self.assertEqual(response.status_code, 302) + + def test_register_for_gathering_requires_login(self): + response = self.client.post(reverse("register_for_gathering", kwargs={"slug": self.gathering.slug})) + self.assertEqual(response.status_code, 302) + + def test_register_for_gathering(self): + self.client.login(username="other_user", password="pass123") + response = self.client.post(reverse("register_for_gathering", kwargs={"slug": self.gathering.slug})) + self.assertEqual(response.status_code, 302) + self.assertTrue( + GatheringRegistration.objects.filter(gathering=self.gathering, attendee=self.other_user).exists() + ) + + def test_cancel_registration(self): + self.client.login(username="other_user", password="pass123") + GatheringRegistration.objects.create(gathering=self.gathering, attendee=self.other_user, status="confirmed") + response = self.client.post(reverse("cancel_gathering_registration", kwargs={"slug": self.gathering.slug})) + self.assertEqual(response.status_code, 302) + self.assertFalse( + GatheringRegistration.objects.filter(gathering=self.gathering, attendee=self.other_user).exists() + ) + + def test_manage_view_requires_organizer(self): + self.client.login(username="other_user", password="pass123") + response = self.client.get(reverse("manage_gathering", kwargs={"slug": self.gathering.slug})) + # Non-organizer is redirected + self.assertEqual(response.status_code, 302) + + def test_manage_view_accessible_by_organizer(self): + self.client.login(username="org_user", password="pass123") + response = self.client.get(reverse("manage_gathering", kwargs={"slug": self.gathering.slug})) + self.assertEqual(response.status_code, 200) + + def test_my_gatherings_requires_login(self): + response = self.client.get(reverse("my_gatherings")) + self.assertRedirects( + response, f"/accounts/login/?next={reverse('my_gatherings')}", fetch_redirect_response=False + ) + + def test_my_gatherings_shows_own_gatherings(self): + self.client.login(username="org_user", password="pass123") + response = self.client.get(reverse("my_gatherings")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Public Meetup") + + def test_delete_gathering_by_organizer(self): + self.client.login(username="org_user", password="pass123") + slug = self.gathering.slug + response = self.client.post(reverse("delete_gathering", kwargs={"slug": slug})) + self.assertEqual(response.status_code, 302) + self.assertFalse(Gathering.objects.filter(slug=slug).exists()) + + def test_private_gathering_hidden_from_non_organizer(self): + private = Gathering.objects.create( + title="Private Event", + description="Organizer only", + gathering_type="other", + organizer=self.organizer, + start_datetime=_make_datetime(5), + end_datetime=_make_datetime(6), + is_virtual=False, + location="Secret location", + status="published", + visibility="private", + ) + response = self.client.get(reverse("gathering_detail", kwargs={"slug": private.slug})) + self.assertEqual(response.status_code, 404) diff --git a/web/urls.py b/web/urls.py index 3fb4e298a..632def547 100644 --- a/web/urls.py +++ b/web/urls.py @@ -27,12 +27,21 @@ SurveyResultsView, add_goods_to_cart, apply_discount_via_referrer, + cancel_gathering_registration, classroom_attendance, + create_gathering, + delete_gathering, + edit_gathering, feature_vote, feature_vote_count, features_page, + gathering_detail, + gathering_list, grade_link, + manage_gathering, + my_gatherings, notification_preferences, + register_for_gathering, sales_analytics, sales_data, streak_detail, @@ -143,6 +152,20 @@ path("surveys//delete/", SurveyDeleteView.as_view(), name="survey-delete"), path("surveys//submit/", submit_survey, name="submit-survey"), path("surveys//results/", SurveyResultsView.as_view(), name="survey-results"), + # Gathering URLs + path("gatherings/", gathering_list, name="gathering_list"), + path("gatherings/create/", create_gathering, name="create_gathering"), + path("gatherings/mine/", my_gatherings, name="my_gatherings"), + path("gatherings//", gathering_detail, name="gathering_detail"), + path("gatherings//edit/", edit_gathering, name="edit_gathering"), + path("gatherings//delete/", delete_gathering, name="delete_gathering"), + path("gatherings//register/", register_for_gathering, name="register_for_gathering"), + path( + "gatherings//cancel-registration/", + cancel_gathering_registration, + name="cancel_gathering_registration", + ), + path("gatherings//manage/", manage_gathering, name="manage_gathering"), # Payment URLs path( "courses//create-payment-intent/", diff --git a/web/views.py b/web/views.py index b4d485749..85a265534 100644 --- a/web/views.py +++ b/web/views.py @@ -78,6 +78,8 @@ FeedbackForm, ForumCategoryForm, ForumTopicForm, + GatheringAnnouncementForm, + GatheringForm, GoodsForm, GradeableLinkForm, InviteStudentForm, @@ -134,6 +136,8 @@ ForumReply, ForumTopic, ForumVote, + Gathering, + GatheringRegistration, Goods, GradeableLink, LearningStreak, @@ -8839,3 +8843,208 @@ def leave_session_waiting_room(request, course_slug): messages.info(request, "You are not in the session waiting room for this course.") return redirect("course_detail", slug=course_slug) + + +# --------------------------------------------------------------------------- +# Gathering views (generic event / meetup / class / workshop) +# --------------------------------------------------------------------------- + + +def gathering_list(request): + """Public list of published gatherings with optional filtering.""" + gatherings = Gathering.objects.filter(status="published", visibility="public").select_related("organizer") + + gathering_type = request.GET.get("type", "") + if gathering_type: + gatherings = gatherings.filter(gathering_type=gathering_type) + + query = request.GET.get("q", "").strip() + if query: + gatherings = gatherings.filter(Q(title__icontains=query) | Q(description__icontains=query)) + + is_virtual = request.GET.get("virtual", "") + if is_virtual == "1": + gatherings = gatherings.filter(is_virtual=True) + elif is_virtual == "0": + gatherings = gatherings.filter(is_virtual=False) + + paginator = Paginator(gatherings, 12) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "page_obj": page_obj, + "gathering_type": gathering_type, + "query": query, + "is_virtual": is_virtual, + "gathering_type_choices": Gathering.GATHERING_TYPE_CHOICES, + } + return render(request, "gatherings/list.html", context) + + +def gathering_detail(request, slug): + """Detail page for a single gathering.""" + gathering = get_object_or_404(Gathering, slug=slug) + + # Enforce visibility + if gathering.visibility == "private" and (not request.user.is_authenticated or request.user != gathering.organizer): + raise Http404 + + user_registration = None + if request.user.is_authenticated: + user_registration = GatheringRegistration.objects.filter(gathering=gathering, attendee=request.user).first() + + announcements = gathering.announcements.all() + + context = { + "gathering": gathering, + "user_registration": user_registration, + "announcements": announcements, + } + return render(request, "gatherings/detail.html", context) + + +@login_required +def create_gathering(request): + """Create a new gathering.""" + if request.method == "POST": + form = GatheringForm(request.POST, request.FILES) + if form.is_valid(): + gathering = form.save(commit=False) + gathering.organizer = request.user + gathering.save() + messages.success(request, _("Gathering created successfully!")) + return redirect("gathering_detail", slug=gathering.slug) + else: + form = GatheringForm() + + return render(request, "gatherings/create.html", {"form": form}) + + +@login_required +def edit_gathering(request, slug): + """Edit an existing gathering (organizer only).""" + gathering = get_object_or_404(Gathering, slug=slug) + if request.user != gathering.organizer: + messages.error(request, _("You are not authorized to edit this gathering.")) + return redirect("gathering_detail", slug=slug) + + if request.method == "POST": + form = GatheringForm(request.POST, request.FILES, instance=gathering) + if form.is_valid(): + form.save() + messages.success(request, _("Gathering updated successfully!")) + return redirect("gathering_detail", slug=gathering.slug) + else: + form = GatheringForm(instance=gathering) + + return render(request, "gatherings/create.html", {"form": form, "gathering": gathering, "editing": True}) + + +@login_required +@require_POST +def delete_gathering(request, slug): + """Delete a gathering (organizer only).""" + gathering = get_object_or_404(Gathering, slug=slug) + if request.user != gathering.organizer: + messages.error(request, _("You are not authorized to delete this gathering.")) + return redirect("gathering_detail", slug=slug) + + gathering.delete() + messages.success(request, _("Gathering deleted.")) + return redirect("gathering_list") + + +@login_required +@require_POST +def register_for_gathering(request, slug): + """Register the current user for a gathering.""" + gathering = get_object_or_404(Gathering, slug=slug, status="published") + + if not gathering.registration_required: + messages.info(request, _("Registration is not required for this gathering.")) + return redirect("gathering_detail", slug=slug) + + if GatheringRegistration.objects.filter(gathering=gathering, attendee=request.user).exists(): + messages.info(request, _("You are already registered for this gathering.")) + return redirect("gathering_detail", slug=slug) + + if gathering.is_full: + # Place on waitlist + GatheringRegistration.objects.create(gathering=gathering, attendee=request.user, status="waitlisted") + messages.info(request, _("The gathering is full. You have been added to the waitlist.")) + return redirect("gathering_detail", slug=slug) + + notes = request.POST.get("notes", "") + reg_status = "confirmed" if gathering.is_free else "pending" + GatheringRegistration.objects.create(gathering=gathering, attendee=request.user, status=reg_status, notes=notes) + if reg_status == "confirmed": + messages.success(request, _("You have successfully registered for this gathering!")) + else: + messages.success(request, _("Registration submitted. Awaiting confirmation from the organizer.")) + + return redirect("gathering_detail", slug=slug) + + +@login_required +@require_POST +def cancel_gathering_registration(request, slug): + """Cancel the current user's registration for a gathering.""" + gathering = get_object_or_404(Gathering, slug=slug) + registration = get_object_or_404(GatheringRegistration, gathering=gathering, attendee=request.user) + registration.delete() + messages.success(request, _("Your registration has been cancelled.")) + return redirect("gathering_detail", slug=slug) + + +@login_required +def manage_gathering(request, slug): + """Organizer view to manage attendees and post announcements.""" + gathering = get_object_or_404(Gathering, slug=slug) + if request.user != gathering.organizer: + messages.error(request, _("You are not authorized to manage this gathering.")) + return redirect("gathering_detail", slug=slug) + + registrations = gathering.registrations.select_related("attendee").order_by("registered_at") + announcements = gathering.announcements.all() + announcement_form = GatheringAnnouncementForm() + + if request.method == "POST": + action = request.POST.get("action") + + if action == "announce": + announcement_form = GatheringAnnouncementForm(request.POST) + if announcement_form.is_valid(): + announcement = announcement_form.save(commit=False) + announcement.gathering = gathering + announcement.author = request.user + announcement.save() + messages.success(request, _("Announcement posted.")) + return redirect("manage_gathering", slug=slug) + + elif action == "update_status": + registration_id = request.POST.get("registration_id") + new_status = request.POST.get("status") + if registration_id and new_status in dict(GatheringRegistration.STATUS_CHOICES): + GatheringRegistration.objects.filter(pk=registration_id, gathering=gathering).update(status=new_status) + messages.success(request, _("Registration status updated.")) + return redirect("manage_gathering", slug=slug) + + context = { + "gathering": gathering, + "registrations": registrations, + "announcements": announcements, + "announcement_form": announcement_form, + "registration_status_choices": GatheringRegistration.STATUS_CHOICES, + } + return render(request, "gatherings/manage.html", context) + + +@login_required +def my_gatherings(request): + """View all gatherings organized by the current user.""" + gatherings = Gathering.objects.filter(organizer=request.user).order_by("-created_at") + paginator = Paginator(gatherings, 10) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + return render(request, "gatherings/my_gatherings.html", {"page_obj": page_obj})