diff --git a/backend/community/__init__.py b/backend/community/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/community/admin.py b/backend/community/admin.py new file mode 100644 index 0000000000..e580905fed --- /dev/null +++ b/backend/community/admin.py @@ -0,0 +1,58 @@ +from django.contrib import admin +from django.http.request import HttpRequest + +from community.models import Community, CommunityMember, Link + + +class LinkInline(admin.TabularInline): + model = Link + + +class CommunityMemberInline(admin.TabularInline): + model = CommunityMember + autocomplete_fields = ("user",) + + +@admin.register(Community) +class CommunityAdmin(admin.ModelAdmin): + list_display = ("name", "hostname", "description") + inlines = [LinkInline, CommunityMemberInline] + fieldsets = ( + ( + "Community", + { + "fields": ( + "name", + "hostname", + "description", + ), + }, + ), + ( + "Landing Page", + { + "fields": ( + "landing_page_primary_color", + "landing_page_secondary_color", + "landing_page_hover_color", + "landing_page_custom_logo_svg", + ), + }, + ), + ) + + def has_change_permission(self, request: HttpRequest, obj=None) -> bool: + if not obj: + return False + + if request.user.is_superuser: + return True + + if ( + obj.members.all() + .filter(user=request.user, role=CommunityMember.Role.ADMIN) + .exists() + ): + return True + + return False diff --git a/backend/community/apps.py b/backend/community/apps.py new file mode 100644 index 0000000000..e9d58130a3 --- /dev/null +++ b/backend/community/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommunityConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "community" diff --git a/backend/community/migrations/0001_initial.py b/backend/community/migrations/0001_initial.py new file mode 100644 index 0000000000..ad7e1b7293 --- /dev/null +++ b/backend/community/migrations/0001_initial.py @@ -0,0 +1,66 @@ +# Generated by Django 5.1.4 on 2025-04-20 21:07 + +import colorfield.fields +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Community', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(max_length=300)), + ('hostname', models.CharField(max_length=256)), + ('description', models.TextField()), + ('landing_page_primary_color', colorfield.fields.ColorField(blank=True, default=None, help_text='Used for the background of the landing page. Depending on the contrast, it will be used to decide if the text should be white or black.', image_field=None, max_length=25, null=True, samples=None)), + ('landing_page_secondary_color', colorfield.fields.ColorField(blank=True, default=None, help_text='Used for the logo color, borders and, if not specified, the hover effect of the links.', image_field=None, max_length=25, null=True, samples=None)), + ('landing_page_hover_color', colorfield.fields.ColorField(blank=True, default=None, help_text='Optional. Used when hovering the links. If empty, it will use the secondary color.', image_field=None, max_length=25, null=True, samples=None)), + ('landing_page_custom_logo_svg', models.TextField(blank=True, help_text='Optional. If empty, it will use the Python Italia logo with the community name below. Copy your SVG code here.', null=True)), + ], + options={ + 'verbose_name_plural': 'Communities', + }, + ), + migrations.CreateModel( + name='CommunityMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('role', models.CharField(choices=[('ADMIN', 'Admin')], default='ADMIN', max_length=300)), + ('community', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='community.community')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='community_memberships', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Link', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('url', models.URLField()), + ('label', models.CharField(max_length=300)), + ('community', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='community.community')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/community/migrations/__init__.py b/backend/community/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/community/models.py b/backend/community/models.py new file mode 100644 index 0000000000..426da54afa --- /dev/null +++ b/backend/community/models.py @@ -0,0 +1,101 @@ +import logging +from django.conf import settings +from django.db import models +import httpx +from model_utils.models import TimeStampedModel +from colorfield.fields import ColorField +from django.db import transaction + + +logger = logging.getLogger(__name__) + + +class Community(TimeStampedModel): + name = models.CharField(max_length=300) + hostname = models.CharField(max_length=256) + description = models.TextField() + + landing_page_primary_color = ColorField( + blank=True, + null=True, + help_text=( + "Used for the background of the landing page. " + "Depending on the contrast, it will be used to decide if " + "the text should be white or black." + ), + ) + landing_page_secondary_color = ColorField( + blank=True, + null=True, + help_text=( + "Used for the logo color, borders " + "and, if not specified, the hover effect of the links." + ), + ) + landing_page_hover_color = ColorField( + blank=True, + null=True, + help_text="Optional. Used when hovering the links. If empty, it will use the secondary color.", + ) + landing_page_custom_logo_svg = models.TextField( + blank=True, + null=True, + help_text=( + "Optional. If empty, it will use the Python Italia logo with the community name below. " + "Copy your SVG code here." + ), + ) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + transaction.on_commit(self.revalidate_landing_page) + + def revalidate_landing_page(self): + # this is very bad :) + try: + for _ in range(3): + response = httpx.post( + f"https://{self.hostname}/api/revalidate/", + json={ + "path": f"/{self.hostname}", + "secret": settings.REVALIDATE_SECRET, + }, + ) + + if response.status_code == 200: + break + + response.raise_for_status() + except Exception as e: + logger.exception("Error while revalidating landing page", exc_info=e) + pass + + def __str__(self) -> str: + return self.name + + class Meta: + verbose_name_plural = "Communities" + + +class CommunityMember(TimeStampedModel): + class Role(models.TextChoices): + ADMIN = "ADMIN" + + user = models.ForeignKey( + "users.User", on_delete=models.CASCADE, related_name="community_memberships" + ) + community = models.ForeignKey( + Community, on_delete=models.CASCADE, related_name="members" + ) + role = models.CharField(choices=Role.choices, default=Role.ADMIN, max_length=300) + + +class Link(TimeStampedModel): + community = models.ForeignKey( + Community, on_delete=models.CASCADE, related_name="links" + ) + url = models.URLField() + label = models.CharField(max_length=300) + + def __str__(self) -> str: + return f"{self.label} - {self.url}" diff --git a/backend/community/views.py b/backend/community/views.py new file mode 100644 index 0000000000..60f00ef0ef --- /dev/null +++ b/backend/community/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/backend/pycon/settings/base.py b/backend/pycon/settings/base.py index 2821bf9fa3..8ff61e15de 100644 --- a/backend/pycon/settings/base.py +++ b/backend/pycon/settings/base.py @@ -75,6 +75,7 @@ "modelcluster", "taggit", "wagtail_headless_preview", + "colorfield", # -- "schedule.apps.ScheduleConfig", "custom_admin", @@ -129,6 +130,7 @@ "billing.apps.BillingConfig", "privacy_policy.apps.PrivacyPolicyConfig", "visa.apps.VisaConfig", + "community.apps.CommunityConfig", ] MIDDLEWARE = [ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b587791fdd..ea6e9dd73f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -104,6 +104,7 @@ dependencies = [ "weasyprint>=63.1", "opencv-python-headless>=4.10.0.84", "psycopg[c]>=3.2.3", + "django-colorfield>=0.13.0", ] name = "backend" version = "0.1.0" diff --git a/backend/uv.lock b/backend/uv.lock index b4566ca73e..f27a730325 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -146,6 +146,7 @@ dependencies = [ { name = "dal-admin-filters" }, { name = "django" }, { name = "django-autocomplete-light" }, + { name = "django-colorfield" }, { name = "django-environ" }, { name = "django-imagekit" }, { name = "django-import-export" }, @@ -230,6 +231,7 @@ requires-dist = [ { name = "dal-admin-filters", specifier = "==1.1.0" }, { name = "django", specifier = "==5.1.4" }, { name = "django-autocomplete-light", specifier = "==3.9.4" }, + { name = "django-colorfield", specifier = ">=0.13.0" }, { name = "django-environ", specifier = "==0.10.0" }, { name = "django-imagekit", specifier = "==5.0.0" }, { name = "django-import-export", specifier = ">=3.2.0,<4.0.0" }, @@ -785,6 +787,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/ae/72/6a6c2d4b05296c9248a1e95430fa4f4605c210c2fac1d7d80d9e7bdf90f0/django-autocomplete-light-3.9.4.tar.gz", hash = "sha256:0f6da75c1c7186698b867a467a8cdb359f0513fdd8e09288a0c2fb018ae3d94e", size = 168936 } +[[package]] +name = "django-colorfield" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/07a6a9b951a0609c03d43df6e4650a4dff5dc35c5863db9b9a279dd1b955/django_colorfield-0.13.0.tar.gz", hash = "sha256:6b91ffbf55a4d6c1eb3f7b46e8b2d757cf124668b1370bd93b2ae34b807cfafc", size = 56517 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/43/84a363c51b513e990cce84a5de54cc36955bdee03ced686767001f364115/django_colorfield-0.13.0-py3-none-any.whl", hash = "sha256:3fa0a33dd8530c346cc1d228950b979c0935f28ddb6dbe200b8accfde636e236", size = 53903 }, +] + [[package]] name = "django-debug-toolbar" version = "5.0.1" @@ -1576,6 +1590,9 @@ name = "markuppy" version = "1.14" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/ca/f43541b41bd17fc945cfae7ea44f1661dc21ea65ecc944a6fa138eead94c/MarkupPy-1.14.tar.gz", hash = "sha256:1adee2c0a542af378fe84548ff6f6b0168f3cb7f426b46961038a2bcfaad0d5f", size = 6815 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/cc/e5403fe82ad2720dd8f821270c5cfe14007b97f9d0c906a834eb613608b2/markuppy-1.14-py3-none-any.whl", hash = "sha256:dc893880c5551d8388b688eef1d425dd48bb0b7d5bc74414fae86764ff4660ee", size = 8449 }, +] [[package]] name = "markupsafe"