diff --git a/backend/Dockerfile b/backend/Dockerfile index 09e37f91..93033e8b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -34,4 +34,4 @@ RUN chmod +x startup.sh EXPOSE 8000 # Run startup script -CMD ["./startup.sh", "gunicorn", "--bind", "0.0.0.0:8000", "--timeout", "180", "--workers", "2", "tally.wsgi:application"] +CMD ["./startup.sh", "gunicorn", "--bind", "0.0.0.0:8000", "--timeout", "180", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--capture-output", "--log-level", "info", "tally.wsgi:application"] diff --git a/backend/deploy-apprunner-dev.sh b/backend/deploy-apprunner-dev.sh index 5c9f0a2e..6f27d85d 100755 --- a/backend/deploy-apprunner-dev.sh +++ b/backend/deploy-apprunner-dev.sh @@ -95,7 +95,7 @@ if aws apprunner describe-service --service-arn arn:aws:apprunner:$REGION:$ACCOU "TWITTER_REDIRECT_URI": "$SSM_PREFIX/$SSM_ENV/twitter_redirect_uri", "DISCORD_REDIRECT_URI": "$SSM_PREFIX/$SSM_ENV/discord_redirect_uri" }, - "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 tally.wsgi:application" + "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 --access-logfile - --error-logfile - --capture-output --log-level info tally.wsgi:application" }, "ImageRepositoryType": "ECR" }, @@ -271,7 +271,7 @@ EOF "TWITTER_REDIRECT_URI": "$SSM_PREFIX/$SSM_ENV/twitter_redirect_uri", "DISCORD_REDIRECT_URI": "$SSM_PREFIX/$SSM_ENV/discord_redirect_uri" }, - "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 tally.wsgi:application" + "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 --access-logfile - --error-logfile - --capture-output --log-level info tally.wsgi:application" }, "ImageRepositoryType": "ECR" }, diff --git a/backend/deploy-apprunner.sh b/backend/deploy-apprunner.sh index 111353cf..d05e3b37 100755 --- a/backend/deploy-apprunner.sh +++ b/backend/deploy-apprunner.sh @@ -234,7 +234,7 @@ if aws apprunner describe-service --service-arn arn:aws:apprunner:$REGION:$ACCOU "DISCORD_ROLE_SUBMISSION_SYNC_GRACE_SECONDS": "$SSM_PREFIX/prod/discord_role_submission_sync_grace_seconds", "DISCORD_REDIRECT_URI": "$SSM_PREFIX/prod/discord_redirect_uri" }, - "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 tally.wsgi:application" + "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 --access-logfile - --error-logfile - --capture-output --log-level info tally.wsgi:application" }, "ImageRepositoryType": "ECR" }, @@ -335,7 +335,7 @@ else "DISCORD_ROLE_SUBMISSION_SYNC_GRACE_SECONDS": "$SSM_PREFIX/prod/discord_role_submission_sync_grace_seconds", "DISCORD_REDIRECT_URI": "$SSM_PREFIX/prod/discord_redirect_uri" }, - "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 tally.wsgi:application" + "StartCommand": "./startup.sh gunicorn --bind 0.0.0.0:8000 --timeout 180 --workers 2 --access-logfile - --error-logfile - --capture-output --log-level info tally.wsgi:application" }, "ImageRepositoryType": "ECR" }, diff --git a/backend/projects/admin.py b/backend/projects/admin.py index 4f509c9d..babe38a7 100644 --- a/backend/projects/admin.py +++ b/backend/projects/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html, format_html_join from utils.admin_mixins import CloudinaryUploadMixin @@ -31,7 +33,7 @@ class ProjectAdmin(CloudinaryUploadMixin, admin.ModelAdmin): search_fields = ('title', 'slug', 'description', 'details', 'user__name', 'user__address') list_editable = ('order', 'status') raw_id_fields = ('user',) - filter_horizontal = ('participants', 'related_contributions') + autocomplete_fields = ('participants', 'related_contributions') prepopulated_fields = {'slug': ('title',)} readonly_fields = ( 'created_at', @@ -40,6 +42,8 @@ class ProjectAdmin(CloudinaryUploadMixin, admin.ModelAdmin): 'hero_image_tablet_public_id', 'hero_image_mobile_public_id', 'user_profile_image_public_id', + 'selected_participants', + 'selected_related_contributions', ) ordering = ('order', '-created_at') @@ -48,7 +52,13 @@ class ProjectAdmin(CloudinaryUploadMixin, admin.ModelAdmin): 'fields': ('title', 'slug', 'author', 'description', 'status', 'order'), }), ('Relations', { - 'fields': ('user', 'participants', 'related_contributions'), + 'fields': ( + 'user', + 'participants', + 'selected_participants', + 'related_contributions', + 'selected_related_contributions', + ), }), ('Project Detail', { 'fields': ( @@ -75,3 +85,57 @@ class ProjectAdmin(CloudinaryUploadMixin, admin.ModelAdmin): 'classes': ('collapse',), }), ) + + @admin.display(description='Selected participants') + def selected_participants(self, obj): + if not obj or not obj.pk: + return 'Save the project before reviewing selected participants.' + + participants = obj.participants.order_by('name', 'email', 'id') + if not participants.exists(): + return 'No participants selected.' + + rows = ( + ( + reverse('admin:users_user_change', args=[participant.pk]), + participant.name or participant.email or participant.address or f'User {participant.pk}', + participant.email or participant.address or '', + ) + for participant in participants + ) + return format_html( + '', + format_html_join('', '
  • {} {}
  • ', rows), + ) + + @admin.display(description='Selected related contributions') + def selected_related_contributions(self, obj): + if not obj or not obj.pk: + return 'Save the project before reviewing selected related contributions.' + + contributions = obj.related_contributions.select_related('user', 'contribution_type').order_by( + 'user__name', + 'user__email', + '-contribution_date', + '-created_at', + ) + if not contributions.exists(): + return 'No related contributions selected.' + + rows = ( + ( + reverse('admin:contributions_contribution_change', args=[contribution.pk]), + contribution.title or f'Contribution {contribution.pk}', + contribution.user.name or contribution.user.email or contribution.user.address, + contribution.contribution_type.name, + ) + for contribution in contributions + ) + return format_html( + '', + format_html_join( + '', + '
  • {} by {} - {}
  • ', + rows, + ), + ) diff --git a/backend/projects/tests/test_projects.py b/backend/projects/tests/test_projects.py index ca345383..075ad0c9 100644 --- a/backend/projects/tests/test_projects.py +++ b/backend/projects/tests/test_projects.py @@ -1,9 +1,13 @@ from datetime import timedelta +from django.contrib.admin.sites import AdminSite +from django.contrib.admin.widgets import AutocompleteSelectMultiple, FilteredSelectMultiple +from django.test import RequestFactory from django.test import TestCase from django.utils import timezone from contributions.models import Category, Contribution, ContributionType, FeaturedContent +from projects.admin import ProjectAdmin from projects.models import Project from stewards.models import Steward from users.models import User @@ -278,3 +282,29 @@ def test_project_detail_404s_for_inactive_project(self): response = self.client.get(f'/api/v1/projects/{project.slug}/') self.assertEqual(response.status_code, 404) + + +class ProjectAdminTest(TestCase): + def test_project_relations_use_autocomplete_widgets(self): + admin_user = User.objects.create_superuser( + email='admin@example.com', + password='pass', + address='0x0000000000000000000000000000000000000009', + name='Admin User', + ) + request = RequestFactory().get('/admin/projects/project/add/') + request.user = admin_user + + project_admin = ProjectAdmin(Project, AdminSite()) + form = project_admin.get_form(request) + readonly_fields = project_admin.get_readonly_fields(request) + + participants_widget = form.base_fields['participants'].widget.widget + contributions_widget = form.base_fields['related_contributions'].widget.widget + + self.assertIsInstance(participants_widget, AutocompleteSelectMultiple) + self.assertIsInstance(contributions_widget, AutocompleteSelectMultiple) + self.assertNotIsInstance(participants_widget, FilteredSelectMultiple) + self.assertNotIsInstance(contributions_widget, FilteredSelectMultiple) + self.assertIn('selected_participants', readonly_fields) + self.assertIn('selected_related_contributions', readonly_fields)