From 7c68762178b358bcffe8f845e5c2144b60817a6e Mon Sep 17 00:00:00 2001 From: Raz Date: Wed, 7 May 2025 13:50:18 +0200 Subject: [PATCH] add sponsor image model --- api/serializers.py | 16 ++++- api/urls.py | 1 + api/views.py | 32 ++++++++- padelclub_backend/settings.py | 4 ++ padelclub_backend/settings_app.py | 2 +- padelclub_backend/urls.py | 6 ++ requirements.txt | 1 + tournaments/admin.py | 67 ++++++++++++++++++- tournaments/migrations/0120_image.py | 33 +++++++++ ...ptions_remove_image_is_primary_and_more.py | 26 +++++++ tournaments/models/__init__.py | 1 + tournaments/models/image.py | 44 ++++++++++++ .../static/tournaments/css/broadcast.css | 7 ++ tournaments/static/tournaments/css/style.css | 2 - .../tournaments/broadcast/broadcast.html | 13 ++++ .../tournaments/broadcast/broadcast_base.html | 5 +- .../broadcast/broadcasted_auto.html | 15 ++++- .../broadcast/broadcasted_bracket.html | 14 +++- .../broadcast/broadcasted_group_stages.html | 14 +++- .../broadcast/broadcasted_matches.html | 15 ++++- .../broadcast/broadcasted_prog.html | 14 +++- .../broadcast/broadcasted_rankings.html | 13 +++- .../broadcast/broadcasted_summons.html | 13 +++- .../tournaments/tournament_info.html | 53 +++++++++++++++ 24 files changed, 395 insertions(+), 16 deletions(-) create mode 100644 tournaments/migrations/0120_image.py create mode 100644 tournaments/migrations/0121_alter_image_options_remove_image_is_primary_and_more.py create mode 100644 tournaments/models/image.py diff --git a/api/serializers.py b/api/serializers.py index c0b081a..a26b7e6 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from tournaments.models.court import Court -from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer +from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer, Image from django.db.utils import IntegrityError from django.conf import settings @@ -239,3 +239,17 @@ class UnregisteredPlayerSerializer(serializers.ModelSerializer): model = UnregisteredPlayer fields = '__all__' # ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid'] + +class ImageSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() + + def get_image_url(self, obj): + if obj.image: + return self.context['request'].build_absolute_uri(obj.image.url) + return None + + class Meta: + model = Image + fields = ['id', 'title', 'description', 'image', 'image_url', 'uploaded_at', + 'event', 'image_type'] + read_only_fields = ['id', 'uploaded_at', 'image_url'] diff --git a/api/urls.py b/api/urls.py index e054f87..2a73de4 100644 --- a/api/urls.py +++ b/api/urls.py @@ -11,6 +11,7 @@ router.register(r'users', views.UserViewSet) router.register(r'user-names', views.ShortUserViewSet) router.register(r'clubs', views.ClubViewSet) router.register(r'tournaments', views.TournamentViewSet) +router.register(r'images', views.ImageViewSet) router.register(r'events', views.EventViewSet) router.register(r'rounds', views.RoundViewSet) router.register(r'group-stages', views.GroupStageViewSet) diff --git a/api/views.py b/api/views.py index be20311..e17f741 100644 --- a/api/views.py +++ b/api/views.py @@ -1,5 +1,5 @@ -from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer -from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer +from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer +from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image from rest_framework import viewsets from rest_framework.response import Response @@ -300,6 +300,34 @@ class ShortUserViewSet(viewsets.ModelViewSet): serializer_class = ShortUserSerializer permission_classes = [] # Users are public whereas the other requests are only for logged users +class ImageViewSet(viewsets.ModelViewSet): + """ + Viewset for handling event image uploads and retrieval. + + This allows umpires/organizers to upload images for events from the iOS app, + which can then be displayed on the event pages. + """ + serializer_class = ImageSerializer + queryset = Image.objects.all() + + def get_queryset(self): + queryset = Image.objects.all() + + # Filter by event + event_id = self.request.query_params.get('event_id') + image_type = self.request.query_params.get('image_type') + + if event_id: + queryset = queryset.filter(event_id=event_id) + if image_type: + queryset = queryset.filter(image_type=image_type) + + return queryset + + def perform_create(self, serializer): + serializer.save() + + @api_view(['POST']) @permission_classes([IsAuthenticated]) def process_refund(request, team_registration_id): diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index b6c8262..31e51fb 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -147,6 +147,10 @@ USE_L10N = True STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'static/') +# Media files (User uploads) +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') + # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field diff --git a/padelclub_backend/settings_app.py b/padelclub_backend/settings_app.py index e69f82e..c10611e 100644 --- a/padelclub_backend/settings_app.py +++ b/padelclub_backend/settings_app.py @@ -46,7 +46,7 @@ QR_CODE_CACHE_ALIAS = 'qr-code' SYNC_APPS = { 'sync': {}, - 'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken'] } + 'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken', 'Image'] } } STRIPE_CURRENCY = 'eur' diff --git a/padelclub_backend/urls.py b/padelclub_backend/urls.py index 85c8b15..abb883a 100644 --- a/padelclub_backend/urls.py +++ b/padelclub_backend/urls.py @@ -15,6 +15,8 @@ Including another URLconf """ from django.contrib import admin from django.urls import include, path +from django.conf import settings +from django.conf.urls.static import static urlpatterns = [ @@ -27,3 +29,7 @@ urlpatterns = [ path('dj-auth/', include('django.contrib.auth.urls')), ] + +# Serve media files in development +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/requirements.txt b/requirements.txt index cc14798..f0cf5e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ django-filter==24.3 cryptography==41.0.7 stripe==11.6.0 django-background-tasks==1.2.8 +Pillow==10.2.0 diff --git a/tournaments/admin.py b/tournaments/admin.py index 5cc7cfb..c9f1039 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -6,7 +6,7 @@ from django.utils.html import escape from django.urls import reverse from django.utils.safestring import mark_safe -from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer +from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image from .forms import CustomUserCreationForm, CustomUserChangeForm from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter @@ -45,10 +45,39 @@ class CustomUserAdmin(UserAdmin): super().save_model(request, obj, form, change) class EventAdmin(SyncedObjectAdmin): - list_display = ['creation_date', 'name', 'club', 'creator', 'creator_full_name', 'tenup_id'] + list_display = ['creation_date', 'name', 'club', 'creator', 'creator_full_name', 'tenup_id', 'display_images'] list_filter = ['creator', 'tenup_id'] raw_id_fields = ['creator'] ordering = ['-creation_date'] + readonly_fields = ['display_images_preview'] + + fieldsets = [ + (None, {'fields': ['name', 'club', 'creator', 'creation_date', 'tenup_id']}), + ('Images', {'fields': ['display_images_preview'], 'classes': ['collapse']}), + ] + + def display_images(self, obj): + count = obj.images.count() + return count if count > 0 else '-' + display_images.short_description = 'Images' + + def display_images_preview(self, obj): + html = '
' + for image in obj.images.all(): + html += f''' +
+ +

+ {image.title or "Untitled"}
+ Type: {image.get_image_type_display()}
+

+
+ ''' + html += '
' + if not obj.images.exists(): + html = '

No images uploaded for this event.

' + return mark_safe(html) + display_images_preview.short_description = 'Images Preview' class TournamentAdmin(SyncedObjectAdmin): list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator'] @@ -157,6 +186,39 @@ class UnregisteredPlayerAdmin(admin.ModelAdmin): list_filter = [] ordering = ['last_name', 'first_name'] +class ImageAdmin(admin.ModelAdmin): + list_display = ['title', 'event', 'image_type', 'order', 'uploaded_at', 'file_size', 'image_preview_small'] + list_filter = ['event', 'image_type', 'uploaded_at'] + search_fields = ['title', 'description', 'event__name'] + ordering = ['order'] + readonly_fields = ['id', 'uploaded_at', 'image_preview', 'file_size'] + raw_id_fields = ['event'] + + def image_preview(self, obj): + if obj.image: + return mark_safe(f'') + return "No Image" + image_preview.short_description = 'Preview' + + def image_preview_small(self, obj): + if obj.image: + return mark_safe(f'') + return "No Image" + image_preview_small.short_description = 'Preview' + + def file_size(self, obj): + if obj.image and hasattr(obj.image, 'size'): + # Convert bytes to KB or MB + size_bytes = obj.image.size + if size_bytes < 1024: + return f"{size_bytes} bytes" + elif size_bytes < 1024 * 1024: + return f"{size_bytes/1024:.1f} KB" + else: + return f"{size_bytes/(1024*1024):.1f} MB" + return "Unknown" + file_size.short_description = 'File Size' + action_flags = { ADDITION: 'Addition', @@ -220,3 +282,4 @@ admin.site.register(DeviceToken, DeviceTokenAdmin) admin.site.register(DrawLog, DrawLogAdmin) admin.site.register(UnregisteredTeam, UnregisteredTeamAdmin) admin.site.register(UnregisteredPlayer, UnregisteredPlayerAdmin) +admin.site.register(Image, ImageAdmin) diff --git a/tournaments/migrations/0120_image.py b/tournaments/migrations/0120_image.py new file mode 100644 index 0000000..8f3f129 --- /dev/null +++ b/tournaments/migrations/0120_image.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1 on 2025-05-07 07:58 + +import django.db.models.deletion +import django.utils.timezone +import tournaments.models.image +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0119_alter_tournament_animation_type'), + ] + + operations = [ + migrations.CreateModel( + name='Image', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(blank=True, max_length=255)), + ('description', models.TextField(blank=True)), + ('image', models.ImageField(upload_to=tournaments.models.image.image_upload_path)), + ('uploaded_at', models.DateTimeField(default=django.utils.timezone.now)), + ('image_type', models.CharField(choices=[('sponsor', 'Sponsor'), ('club', 'Club')], default='sponsor', max_length=20)), + ('is_primary', models.BooleanField(default=False)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='tournaments.event')), + ], + options={ + 'ordering': ['-uploaded_at'], + }, + ), + ] diff --git a/tournaments/migrations/0121_alter_image_options_remove_image_is_primary_and_more.py b/tournaments/migrations/0121_alter_image_options_remove_image_is_primary_and_more.py new file mode 100644 index 0000000..aeea9a3 --- /dev/null +++ b/tournaments/migrations/0121_alter_image_options_remove_image_is_primary_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1 on 2025-05-07 11:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0120_image'), + ] + + operations = [ + migrations.AlterModelOptions( + name='image', + options={'ordering': ['order']}, + ), + migrations.RemoveField( + model_name='image', + name='is_primary', + ), + migrations.AddField( + model_name='image', + name='order', + field=models.IntegerField(default=0), + ), + ] diff --git a/tournaments/models/__init__.py b/tournaments/models/__init__.py index 9dd7d03..ee3cccb 100644 --- a/tournaments/models/__init__.py +++ b/tournaments/models/__init__.py @@ -21,3 +21,4 @@ from .device_token import DeviceToken from .draw_log import DrawLog from .unregistered_team import UnregisteredTeam from .unregistered_player import UnregisteredPlayer +from .image import Image diff --git a/tournaments/models/image.py b/tournaments/models/image.py new file mode 100644 index 0000000..227cbcd --- /dev/null +++ b/tournaments/models/image.py @@ -0,0 +1,44 @@ +from django.db import models +import uuid +import os +from django.utils.timezone import now +from .event import Event + +def image_upload_path(instance, filename): + """Generate a unique file path for the uploaded image.""" + # Get the file extension from the original filename + ext = filename.split('.')[-1] + # Create a unique filename using UUID + unique_filename = f"{uuid.uuid4().hex}.{ext}" + + # Determine the folder based on the event + folder = f"event_{instance.event.id}" + + # Return the complete upload path + return os.path.join('images', folder, unique_filename) + +class Image(models.Model): + """Model for storing uploaded images for events.""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + title = models.CharField(max_length=255, blank=True) + description = models.TextField(blank=True) + image = models.ImageField(upload_to=image_upload_path) + uploaded_at = models.DateTimeField(default=now) + + # Relation to event model + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='images') + + # Image type for filtering + IMAGE_TYPES = ( + ('sponsor', 'Sponsor'), + ('club', 'Club'), + ) + image_type = models.CharField(max_length=20, choices=IMAGE_TYPES, default='sponsor') + + order = models.IntegerField(default=0) + + class Meta: + ordering = ['order'] + + def __str__(self): + return self.title or f"Event Image {self.id}" diff --git a/tournaments/static/tournaments/css/broadcast.css b/tournaments/static/tournaments/css/broadcast.css index 2975558..f106b0f 100644 --- a/tournaments/static/tournaments/css/broadcast.css +++ b/tournaments/static/tournaments/css/broadcast.css @@ -22,6 +22,13 @@ body { box-shadow: 0 0 0px 0px #fbead6; } +.bubble-header { + padding: 30px; + background-color: white; + border-radius: 24px; + box-shadow: 0 0 0px 0px #fbead6; +} + .bold { font-family: "Montserrat-Bold"; } diff --git a/tournaments/static/tournaments/css/style.css b/tournaments/static/tournaments/css/style.css index 9820993..fbc523a 100644 --- a/tournaments/static/tournaments/css/style.css +++ b/tournaments/static/tournaments/css/style.css @@ -297,8 +297,6 @@ tr { .logo { height: 80px; - margin: 20px 0px; - /* padding: 5px 10px; */ } .padding-bottom-small { diff --git a/tournaments/templates/tournaments/broadcast/broadcast.html b/tournaments/templates/tournaments/broadcast/broadcast.html index 1fe31fc..f6c1243 100644 --- a/tournaments/templates/tournaments/broadcast/broadcast.html +++ b/tournaments/templates/tournaments/broadcast/broadcast.html @@ -6,6 +6,19 @@ {% block first_title %}{{ tournament.event.display_name }}{% endblock %} {% block second_title %}Broadcast{% endblock %} +{% block sponsors %} + {% if tournament.event.images.exists %} +
+
+ {% for image in tournament.event.images.all %} + {{ image.title|default:'Sponsor' }} + {% endfor %} +
+
+ {% endif %} +{% endblock %} + {% block content %}
diff --git a/tournaments/templates/tournaments/broadcast/broadcast_base.html b/tournaments/templates/tournaments/broadcast/broadcast_base.html index b4667b5..0d3cdce 100644 --- a/tournaments/templates/tournaments/broadcast/broadcast_base.html +++ b/tournaments/templates/tournaments/broadcast/broadcast_base.html @@ -34,7 +34,7 @@