diff --git a/auths/management/__init__.py b/auths/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auths/management/commands/__init__.py b/auths/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auths/management/commands/make_admin.py b/auths/management/commands/make_admin.py new file mode 100644 index 0000000..11b3a89 --- /dev/null +++ b/auths/management/commands/make_admin.py @@ -0,0 +1,19 @@ +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand + +from root.env_config import env + + +class Command(BaseCommand): + help = "Create an admin user if it does not exist" + + def handle(self, *args, **kwargs): + admin_username = env("DJANGO_ADMIN_USERNAME") + admin_password = env("DJANGO_ADMIN_PASSWORD") + admin_email = env("DJANGO_ADMIN_EMAIL") + + if not User.objects.filter(username=admin_username).exists(): + User.objects.create_superuser(admin_username, admin_email, admin_password) + self.stdout.write(self.style.SUCCESS(f'Admin user "{admin_username}" created')) + else: + self.stdout.write(self.style.WARNING(f'Admin user "{admin_username}" already exists')) diff --git a/env.example b/env.example index 27261e1..4f97620 100644 --- a/env.example +++ b/env.example @@ -1,3 +1,8 @@ +# Django Superuser details +DJANGO_ADMIN_USERNAME="admin" +DJANGO_ADMIN_EMAIL="admin@admin.com" +DJANGO_ADMIN_PASSWORD="admin" + # WARNING: Set to False in production to enable security features DJANGO_DEBUG=False diff --git a/events/admin.py b/events/admin.py index 943714e..87b197a 100644 --- a/events/admin.py +++ b/events/admin.py @@ -1,10 +1,15 @@ from django.contrib import admin from django.db.models import Q -from root.base_admin import SummernoteModelAdmin # type: ignore - -from .enums import EventStatus -from .models import Banner, Event, EventSignup, Location, Schedule +from events.enums import EventStatus +from events.filters.category import ActiveCategoryFilter +from events.models.banner import Banner +from events.models.category import Category +from events.models.event import Event +from events.models.location import Location +from events.models.schedule import Schedule +from events.models.signup import EventSignup +from root.base_admin import SummernoteModelAdmin class LocationInline(admin.StackedInline): @@ -29,12 +34,22 @@ class ScheduleInline(admin.StackedInline): class EventAdmin(SummernoteModelAdmin, admin.ModelAdmin): list_display = ( "title", + "category", "total_participants", "formatted_created_at", "status", ) - search_fields = ("title", "description", "location") - list_filter = ("created_at", "updated_at", "status") + search_fields = ( + "title", + "description", + "status", + "category__name", + ) + list_filter = ( + "created_at", + "status", + ActiveCategoryFilter, + ) readonly_fields = ["created_by"] inlines = [LocationInline, ScheduleInline, BannerInline] @@ -60,8 +75,16 @@ def get_queryset(self, request): @admin.register(EventSignup) class EventSignupAdmin(admin.ModelAdmin): - list_display = ("user", "event", "signup_date") - search_fields = ("user", "event", "signup_date") + list_display = ( + "user", + "event", + "signup_date", + ) + search_fields = ( + "user", + "event", + "signup_date", + ) list_filter = ( "event", "signup_date", @@ -71,20 +94,70 @@ class EventSignupAdmin(admin.ModelAdmin): @admin.register(Location) class LocationAdmin(admin.ModelAdmin): - list_display = ("address", "google_map_link") - search_fields = ("address", "google_map_link") + list_display = ( + "address", + "google_map_link", + ) + search_fields = ( + "address", + "google_map_link", + ) list_filter = ("address",) @admin.register(Banner) class BannerAdmin(admin.ModelAdmin): - list_display = ("event", "image") - search_fields = ("event", "image") + list_display = ( + "event", + "image", + ) + search_fields = ( + "event", + "image", + ) list_filter = ("event",) @admin.register(Schedule) class ScheduleAdmin(admin.ModelAdmin): - list_display = ("event", "start_date", "start_time", "end_date", "end_time") - search_fields = ("event", "start_date", "start_time", "end_date", "end_time") - list_filter = ("event", "start_date", "start_time", "end_date", "end_time") + list_display = ( + "event", + "start_date", + "start_time", + "end_date", + "end_time", + ) + search_fields = ( + "event", + "start_date", + "start_time", + "end_date", + "end_time", + ) + list_filter = ( + "event", + "start_date", + "start_time", + "end_date", + "end_time", + ) + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ( + "name", + "created_at", + "is_active", + ) + search_fields = ( + "name", + "is_active", + ) + list_filter = ( + "name", + "is_active", + ) + readonly_fields = [ + "created_at", + ] diff --git a/events/filters/__init__.py b/events/filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/filters/category.py b/events/filters/category.py new file mode 100644 index 0000000..f151999 --- /dev/null +++ b/events/filters/category.py @@ -0,0 +1,17 @@ +from django.contrib.admin import SimpleListFilter + +from events.models.category import Category + + +class ActiveCategoryFilter(SimpleListFilter): + title = "Active Category" + parameter_name = "category" + + def lookups(self, request, model_admin): # noqa: PLR6301 + active_categories = Category.objects.filter(is_active=True).order_by("name") + return [(category.id, category.name) for category in active_categories] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(category__id=self.value()) + return queryset diff --git a/events/management/__init__.py b/events/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/management/commands/__init__.py b/events/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/management/commands/fake_events.py b/events/management/commands/fake_events.py index dbf83b2..88c0515 100644 --- a/events/management/commands/fake_events.py +++ b/events/management/commands/fake_events.py @@ -8,7 +8,11 @@ from PIL import Image from events.enums import EventStatus -from events.models import Banner, Event, Location, Schedule +from events.models.banner import Banner +from events.models.category import Category +from events.models.event import Event +from events.models.location import Location +from events.models.schedule import Schedule fake = Faker() @@ -21,12 +25,15 @@ def handle(self, *args, **kwargs): username="fakeuser", defaults={"password": "fakepassword"} ) + for _ in range(4): + self.create_category() + for _ in range(10): description_html = "".join(fake.paragraphs(nb=3, ext_word_list=None)) - event = Event.objects.create( title=fake.sentence(nb_words=6), description=f"

{description_html}

", + category=Category.objects.filter(is_active=True).order_by("?").first(), total_participants=fake.random_int(min=10, max=100), created_by=user, status=EventStatus.DRAFT, @@ -41,6 +48,16 @@ def handle(self, *args, **kwargs): self.stdout.write(self.style.SUCCESS(f"Successfully created event: {event.title}")) + @staticmethod + def create_image(): + image = Image.new("RGB", (100, 100), color="blue") + image_io = BytesIO() + image.save(image_io, format="PNG") + image_io.seek(0) + + image_name = fake.uuid4() + ".png" + return ContentFile(image_io.read(), image_name) + @staticmethod def create_schedule(event): today = datetime.today().date() @@ -61,19 +78,10 @@ def create_schedule(event): end_time=end_time, ) - @staticmethod - def create_banner(event): - image = Image.new("RGB", (100, 100), color="blue") - image_io = BytesIO() - image.save(image_io, format="PNG") - image_io.seek(0) - - image_name = fake.uuid4() + ".png" - image_file = ContentFile(image_io.read(), image_name) - + def create_banner(self, event): Banner.objects.create( event=event, - image=image_file, + image=self.create_image(), uploaded_at=fake.date_time_this_year(), ) @@ -84,3 +92,11 @@ def create_location(event): address=fake.address(), google_map_link=fake.url(), ) + + def create_category(self): + return Category.objects.create( + name=fake.word(), + description=fake.sentence(), + icon=self.create_image(), + is_active=fake.boolean(chance_of_getting_true=50), + ) diff --git a/events/migrations/0021_category_event_categorty.py b/events/migrations/0021_category_event_categorty.py new file mode 100644 index 0000000..151bf55 --- /dev/null +++ b/events/migrations/0021_category_event_categorty.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.4 on 2024-12-07 08:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0020_alter_eventsignup_user'), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('icon', models.ImageField(blank=True, null=True, upload_to='category_icons/')), + ('is_active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.AddField( + model_name='event', + name='categorty', + field=models.ForeignKey(default=1, limit_choices_to={'is_active': True}, on_delete=django.db.models.deletion.PROTECT, to='events.category'), + preserve_default=False, + ), + ] diff --git a/events/migrations/0022_rename_categorty_event_category.py b/events/migrations/0022_rename_categorty_event_category.py new file mode 100644 index 0000000..482ba5e --- /dev/null +++ b/events/migrations/0022_rename_categorty_event_category.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2024-12-07 08:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0021_category_event_categorty'), + ] + + operations = [ + migrations.RenameField( + model_name='event', + old_name='categorty', + new_name='category', + ), + ] diff --git a/events/models.py b/events/models.py deleted file mode 100644 index 5a72cc0..0000000 --- a/events/models.py +++ /dev/null @@ -1,115 +0,0 @@ -from django.contrib.auth.models import User -from django.db import models - -from events.enums import EventStatus -from events.managers.banners import BannerManager -from events.validators import ( - validate_event_attributes, - validate_event_capacity, - validate_event_dates_and_time, - validate_event_exists, - validate_google_map_link, - validate_total_participants, -) - - -class Event(models.Model): - title = models.CharField(max_length=200) - description = models.TextField() - total_participants = models.PositiveIntegerField() - created_by = models.ForeignKey(User, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True, editable=False) - updated_at = models.DateTimeField(auto_now=True) - status = models.CharField( - max_length=10, - choices=EventStatus.choices, - default=EventStatus.DRAFT, - ) - - class Meta: - ordering = ["created_at"] - verbose_name = "Event" - verbose_name_plural = "Event" - - def clean(self): - validate_total_participants(self) - validate_event_attributes(self, "status") - - def __str__(self): - return f"Event: {self.title}" - - -class EventSignup(models.Model): - user = models.ForeignKey( - User, on_delete=models.CASCADE, limit_choices_to={"is_active": True}, db_index=True - ) - event = models.ForeignKey( - Event, - on_delete=models.PROTECT, - limit_choices_to={"status": EventStatus.ACTIVE}, - db_index=True, - ) - signup_date = models.DateTimeField(auto_now_add=True, editable=False) - - class Meta: - unique_together = ("user", "event") - ordering = ["signup_date"] - verbose_name = "Attendee" - verbose_name_plural = "Attendee" - - def clean(self): - validate_event_exists(self) - validate_event_attributes(self, "event") - validate_event_capacity(self) - - def __str__(self): - return f"{self.user.username} has signed up for {self.event.title} event." - - -class Location(models.Model): - event = models.OneToOneField( - Event, - on_delete=models.CASCADE, - related_name="location", - ) - address = models.CharField(max_length=255) - google_map_link = models.URLField(max_length=500) - - def clean(self): - validate_google_map_link(self) - - def __str__(self): - return self.address - - -class Banner(models.Model): - event = models.ForeignKey( - Event, - on_delete=models.CASCADE, - related_name="banner", - ) - image = models.ImageField(upload_to="event_banners/") - uploaded_at = models.DateTimeField(auto_now_add=True) - - objects = BannerManager() - - def __str__(self): - return f"Banner for {self.event.title} uploaded at {self.uploaded_at}" - - -class Schedule(models.Model): - event = models.OneToOneField( - Event, - on_delete=models.CASCADE, - related_name="schedule", - ) - start_date = models.DateField() - end_date = models.DateField() - start_time = models.TimeField(default="00:00:00") - end_time = models.TimeField(default="00:00:00") - - def clean(self): - validate_event_dates_and_time(self) - - def __str__(self): - return f"Schedule for {self.event.title}" diff --git a/events/models/__init__.py b/events/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/models/banner.py b/events/models/banner.py new file mode 100644 index 0000000..1371376 --- /dev/null +++ b/events/models/banner.py @@ -0,0 +1,19 @@ +from django.db import models + +from events.managers.banners import BannerManager +from events.models.event import Event + + +class Banner(models.Model): + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="banner", + ) + image = models.ImageField(upload_to="event_banners/") + uploaded_at = models.DateTimeField(auto_now_add=True) + + objects = BannerManager() + + def __str__(self): + return f"Banner for {self.event.title} uploaded at {self.uploaded_at}" diff --git a/events/models/category.py b/events/models/category.py new file mode 100644 index 0000000..8cbd9e6 --- /dev/null +++ b/events/models/category.py @@ -0,0 +1,13 @@ +from django.db import models + + +class Category(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.TextField(blank=True, null=True) + icon = models.ImageField(upload_to="category_icons/", blank=True, null=True) + is_active = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name diff --git a/events/models/event.py b/events/models/event.py new file mode 100644 index 0000000..3877827 --- /dev/null +++ b/events/models/event.py @@ -0,0 +1,39 @@ +from django.contrib.auth.models import User +from django.db import models + +from events.enums import EventStatus +from events.validators import ( + validate_event_attributes, + validate_total_participants, +) + + +class Event(models.Model): + title = models.CharField(max_length=200) + description = models.TextField() + category = models.ForeignKey( + "Category", + on_delete=models.PROTECT, + limit_choices_to={"is_active": True}, + ) + total_participants = models.PositiveIntegerField() + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True) + status = models.CharField( + max_length=10, + choices=EventStatus.choices, + default=EventStatus.DRAFT, + ) + + class Meta: + ordering = ["created_at"] + verbose_name = "Event" + verbose_name_plural = "Event" + + def clean(self): + validate_total_participants(self) + validate_event_attributes(self, "status") + + def __str__(self): + return f"Event: {self.title}" diff --git a/events/models/location.py b/events/models/location.py new file mode 100644 index 0000000..2751ef9 --- /dev/null +++ b/events/models/location.py @@ -0,0 +1,20 @@ +from django.db import models + +from events.models.event import Event +from events.validators import validate_google_map_link + + +class Location(models.Model): + event = models.OneToOneField( + Event, + on_delete=models.CASCADE, + related_name="location", + ) + address = models.CharField(max_length=255) + google_map_link = models.URLField(max_length=500) + + def clean(self): + validate_google_map_link(self) + + def __str__(self): + return self.address diff --git a/events/models/schedule.py b/events/models/schedule.py new file mode 100644 index 0000000..5c5034e --- /dev/null +++ b/events/models/schedule.py @@ -0,0 +1,22 @@ +from django.db import models + +from events.models.event import Event +from events.validators import validate_event_dates_and_time + + +class Schedule(models.Model): + event = models.OneToOneField( + Event, + on_delete=models.CASCADE, + related_name="schedule", + ) + start_date = models.DateField() + end_date = models.DateField() + start_time = models.TimeField(default="00:00:00") + end_time = models.TimeField(default="00:00:00") + + def clean(self): + validate_event_dates_and_time(self) + + def __str__(self): + return f"Schedule for {self.event.title}" diff --git a/events/models/signup.py b/events/models/signup.py new file mode 100644 index 0000000..d430af4 --- /dev/null +++ b/events/models/signup.py @@ -0,0 +1,37 @@ +from django.contrib.auth.models import User +from django.db import models + +from events.enums import EventStatus +from events.models.event import Event +from events.validators import ( + validate_event_attributes, + validate_event_capacity, + validate_event_exists, +) + + +class EventSignup(models.Model): + user = models.ForeignKey( + User, on_delete=models.CASCADE, limit_choices_to={"is_active": True}, db_index=True + ) + event = models.ForeignKey( + Event, + on_delete=models.PROTECT, + limit_choices_to={"status": EventStatus.ACTIVE}, + db_index=True, + ) + signup_date = models.DateTimeField(auto_now_add=True, editable=False) + + class Meta: + unique_together = ("user", "event") + ordering = ["signup_date"] + verbose_name = "Attendee" + verbose_name_plural = "Attendee" + + def clean(self): + validate_event_exists(self) + validate_event_attributes(self, "event") + validate_event_capacity(self) + + def __str__(self): + return f"{self.user.username} has signed up for {self.event.title} event." diff --git a/events/serializers.py b/events/serializers.py index 8845233..41d0ec4 100644 --- a/events/serializers.py +++ b/events/serializers.py @@ -3,7 +3,8 @@ from rest_framework import serializers from events.enums import EventStatus -from events.models import Event, EventSignup +from events.models.event import Event +from events.models.signup import EventSignup class EventSerializer(serializers.ModelSerializer): diff --git a/events/signals.py b/events/signals.py index b5e7e3f..e6270e8 100644 --- a/events/signals.py +++ b/events/signals.py @@ -3,7 +3,12 @@ from django.dispatch import receiver from events.enums import EventStatus -from events.models import Banner, Event, EventSignup, Location, Schedule +from events.models.banner import Banner +from events.models.category import Category +from events.models.event import Event +from events.models.location import Location +from events.models.schedule import Schedule +from events.models.signup import EventSignup from preferences.enums import EmailTemplateType from preferences.models import EmailTemplate from root.tasks import send_email_task @@ -124,3 +129,18 @@ def validate_banner(instance, **kwargs): if event.status in [EventStatus.COMPLETED, EventStatus.CANCELLED]: raise PermissionDenied("You cannot add a banner for a cancelled/completed event.") instance.full_clean() + + +@receiver(pre_save, sender=Category) +def restrict_category_update(instance, **kwargs): + """ + Restrict the update of a category if it is associated with a active/completed event. + """ + if ( + instance.pk + and instance.event_set.filter( + status__in=[EventStatus.ACTIVE.name, EventStatus.COMPLETED.name] + ).exists() + ): + raise PermissionDenied("You cannot update a category linked to an active/completed event.") + instance.full_clean() diff --git a/events/views.py b/events/views.py index 4e3c7a5..75cec46 100644 --- a/events/views.py +++ b/events/views.py @@ -3,8 +3,10 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from events.models.event import Event +from events.models.signup import EventSignup + from .enums import EventStatus -from .models import Event, EventSignup from .serializers import EventSerializer, EventSignupSerializer diff --git a/preferences/management/__init__.py b/preferences/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/preferences/management/commands/__init__.py b/preferences/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/root/jazzmin.py b/root/jazzmin.py index 586ce2f..753e7b8 100644 --- a/root/jazzmin.py +++ b/root/jazzmin.py @@ -89,6 +89,7 @@ "events.location": "fas fa-map-marker-alt", "events.banner": "fas fa-image", "events.schedule": "fas fa-clock", + "events.category": "fas fa-tags", # Preferences icons "preferences": "fas fa-cogs", "preferences.emailtemplate": "fas fa-envelope",