From b98d6553e1f42b06f9931eeee3e5bf2df6175c32 Mon Sep 17 00:00:00 2001 From: MayaK Date: Tue, 20 May 2025 12:57:32 -0400 Subject: [PATCH 1/6] Adding Speakers Application skeleton --- portal/settings.py | 1 + portal/urls.py | 1 + speaker/__init__.py | 0 speaker/admin.py | 0 speaker/apps.py | 6 + speaker/constants.py | 31 ++ .../speaker_profile_email_notification.html | 0 .../speaker_profile_email_notification.txt | 0 speaker/forms.py | 67 ++++ speaker/languages.py | 321 ++++++++++++++++++ speaker/models.py | 249 ++++++++++++++ speaker/tests.py | 0 speaker/urls.py | 35 ++ speaker/views.py | 73 ++++ storage_backend/custom_storage.py | 8 - templates/portal/base.html | 3 + templates/speaker/index.html | 27 ++ templates/speaker/speakerprofile_detail.html | 222 ++++++++++++ templates/speaker/speakerprofile_form.html | 0 19 files changed, 1036 insertions(+), 8 deletions(-) create mode 100644 speaker/__init__.py create mode 100644 speaker/admin.py create mode 100644 speaker/apps.py create mode 100644 speaker/constants.py create mode 100644 speaker/email/speaker_profile_email_notification.html create mode 100644 speaker/email/speaker_profile_email_notification.txt create mode 100644 speaker/forms.py create mode 100644 speaker/languages.py create mode 100644 speaker/models.py create mode 100644 speaker/tests.py create mode 100644 speaker/urls.py create mode 100644 speaker/views.py create mode 100644 templates/speaker/index.html create mode 100644 templates/speaker/speakerprofile_detail.html create mode 100644 templates/speaker/speakerprofile_form.html diff --git a/portal/settings.py b/portal/settings.py index 57411d43..b7dffa9b 100644 --- a/portal/settings.py +++ b/portal/settings.py @@ -48,6 +48,7 @@ "storages", "portal", "volunteer", + "speaker", "portal_account", "widget_tweaks", ] diff --git a/portal/urls.py b/portal/urls.py index fe9b8327..63ee8b34 100644 --- a/portal/urls.py +++ b/portal/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ path("", views.index, name="index"), path("volunteer/", include("volunteer.urls", namespace="volunteer")), + path("speaker/", include("speaker.urls", namespace="speaker")), path("admin/", admin.site.urls), path("accounts/", include("allauth.urls")), path( diff --git a/speaker/__init__.py b/speaker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/speaker/admin.py b/speaker/admin.py new file mode 100644 index 00000000..e69de29b diff --git a/speaker/apps.py b/speaker/apps.py new file mode 100644 index 00000000..65bb0a6f --- /dev/null +++ b/speaker/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class VolunteerConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "speaker" diff --git a/speaker/constants.py b/speaker/constants.py new file mode 100644 index 00000000..45580333 --- /dev/null +++ b/speaker/constants.py @@ -0,0 +1,31 @@ +from enum import StrEnum + + +# class RoleTypes(StrEnum): +# """Role types for the volunteer.""" + +# ADMIN = "Admin" +# STAFF = "Staff" +# VENDOR = "Vendor" +# VOLUNTEER = "Volunteer" + + +class ApplicationStatus(StrEnum): + """Application status for the volunteer.""" + + PENDING = "Pending Review" + APPROVED = "Approved" + REJECTED = "Rejected" + CANCELLED = "Cancelled" + + +class Region(StrEnum): + """Region where the volunteer usually reside.""" + + NO_REGION = "" + ASIA = "Asia" + EUROPE = "Europe" + NORTH_AMERICA = "North America" + SOUTH_AMERICA = "South America" + AFRICA = "Africa" + OCEANIA = "Oceania" diff --git a/speaker/email/speaker_profile_email_notification.html b/speaker/email/speaker_profile_email_notification.html new file mode 100644 index 00000000..e69de29b diff --git a/speaker/email/speaker_profile_email_notification.txt b/speaker/email/speaker_profile_email_notification.txt new file mode 100644 index 00000000..e69de29b diff --git a/speaker/forms.py b/speaker/forms.py new file mode 100644 index 00000000..d6b222c8 --- /dev/null +++ b/speaker/forms.py @@ -0,0 +1,67 @@ +import re + +from django import forms +from django.core.exceptions import ValidationError +from django.forms import ModelForm +from django.forms.widgets import SelectMultiple + +from .languages import LANGUAGES +from .models import SpeakerProfile + +class LanguageSelectMultiple(SelectMultiple): + """ + A custom widget for selecting multiple languages with autocomplete. + """ + + def __init__(self, attrs=None, choices=()): + default_attrs = { + "class": "form-control select2-multiple", + "data-placeholder": "Start typing to select languages...", + } + if attrs: + default_attrs.update(attrs) + super().__init__(default_attrs, choices) + +class SpeakerProfileForm(ModelForm): + + # discord_username = forms.CharField(required=True) + additional_comments = forms.CharField(widget=forms.Textarea, required=False) + + class Meta: + model = SpeakerProfile + exclude = ["user", "application_status"] + help_texts = { + # "github_username": "GitHub username (e.g., username)", + # "discord_username": "Required - Your Discord username for team communication (e.g., username#1234)", + # "instagram_username": "Instagram username without @ (e.g., username)", + # "bluesky_username": "Bluesky username (e.g., username or username.bsky.social)", + # "mastodon_url": "Mastodon handle (e.g., @username@instance.tld or https://instance.tld/@username)", + # "x_username": "X/Twitter username without @ (e.g., username)", + # "linkedin_url": "LinkedIn URL (e.g., linkedin.com/in/username)", + "region": "Region where you normally reside", + } + + def clean(self): + cleaned_data = super().clean() + return cleaned_data + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) + super().__init__(*args, **kwargs) + + sorted_languages = sorted(LANGUAGES, key=lambda x: x[1]) + + self.fields["discord_username"].required = True + self.fields["languages_spoken"].choices = sorted_languages + self.fields["languages_spoken"].widget = LanguageSelectMultiple( + choices=sorted_languages + ) + + if self.instance and self.instance.pk: + pass + + def save(self, commit=True): + if self.user: + self.instance.user = self.user + volunteer_profile = super().save(commit) + return volunteer_profile diff --git a/speaker/languages.py b/speaker/languages.py new file mode 100644 index 00000000..4b462430 --- /dev/null +++ b/speaker/languages.py @@ -0,0 +1,321 @@ +""" +This module contains a list of language choices based on the Wikipedia page +"List of language names" (https://en.wikipedia.org/wiki/List_of_language_names). + +The list is used to replace Django's default LANGUAGES list for the languages_spoken field +in the VolunteerProfile model. + +For languages that don't have standard ISO 639-1 two-letter codes, unique codes have been +invented following the pattern of existing codes. +""" + +# List of language choices based on https://en.wikipedia.org/wiki/List_of_language_names +# Format: (language_code, language_name) +LANGUAGES = [ + ("aa", "Afar"), + ("ab", "Abkhazian"), + ("ae", "Avestan"), + ("af", "Afrikaans"), + ("ak", "Akan"), + ("am", "Amharic"), + ("an", "Aragonese"), + ("ar", "Arabic"), + ("as", "Assamese"), + ("av", "Avaric"), + ("ay", "Aymara"), + ("az", "Azerbaijani"), + ("ba", "Bashkir"), + ("be", "Belarusian"), + ("bg", "Bulgarian"), + ("bh", "Bihari languages"), + ("bi", "Bislama"), + ("bm", "Bambara"), + ("bn", "Bengali"), + ("bo", "Tibetan"), + ("br", "Breton"), + ("bs", "Bosnian"), + ("ca", "Catalan"), + ("ce", "Chechen"), + ("ch", "Chamorro"), + ("co", "Corsican"), + ("cr", "Cree"), + ("cs", "Czech"), + ("cu", "Church Slavic"), + ("cv", "Chuvash"), + ("cy", "Welsh"), + ("da", "Danish"), + ("de", "German"), + ("dv", "Divehi"), + ("dz", "Dzongkha"), + ("ee", "Ewe"), + ("el", "Greek"), + ("en", "English"), + ("eo", "Esperanto"), + ("es", "Spanish"), + ("et", "Estonian"), + ("eu", "Basque"), + ("fa", "Persian"), + ("ff", "Fulah"), + ("fi", "Finnish"), + ("fj", "Fijian"), + ("fo", "Faroese"), + ("fr", "French"), + ("fy", "Western Frisian"), + ("ga", "Irish"), + ("gd", "Gaelic"), + ("gl", "Galician"), + ("gn", "Guarani"), + ("gu", "Gujarati"), + ("gv", "Manx"), + ("ha", "Hausa"), + ("he", "Hebrew"), + ("hi", "Hindi"), + ("ho", "Hiri Motu"), + ("hr", "Croatian"), + ("ht", "Haitian"), + ("hu", "Hungarian"), + ("hy", "Armenian"), + ("hz", "Herero"), + ("ia", "Interlingua"), + ("id", "Indonesian"), + ("ie", "Interlingue"), + ("ig", "Igbo"), + ("ii", "Sichuan Yi"), + ("ik", "Inupiaq"), + ("io", "Ido"), + ("is", "Icelandic"), + ("it", "Italian"), + ("iu", "Inuktitut"), + ("ja", "Japanese"), + ("jv", "Javanese"), + ("ka", "Georgian"), + ("kg", "Kongo"), + ("ki", "Kikuyu"), + ("kj", "Kuanyama"), + ("kk", "Kazakh"), + ("kl", "Kalaallisut"), + ("km", "Khmer"), + ("kn", "Kannada"), + ("ko", "Korean"), + ("kr", "Kanuri"), + ("ks", "Kashmiri"), + ("ku", "Kurdish"), + ("kv", "Komi"), + ("kw", "Cornish"), + ("ky", "Kirghiz"), + ("la", "Latin"), + ("lb", "Luxembourgish"), + ("lg", "Ganda"), + ("li", "Limburgan"), + ("ln", "Lingala"), + ("lo", "Lao"), + ("lt", "Lithuanian"), + ("lu", "Luba-Katanga"), + ("lv", "Latvian"), + ("mg", "Malagasy"), + ("mh", "Marshallese"), + ("mi", "Maori"), + ("mk", "Macedonian"), + ("ml", "Malayalam"), + ("mn", "Mongolian"), + ("mr", "Marathi"), + ("ms", "Malay"), + ("mt", "Maltese"), + ("my", "Burmese"), + ("na", "Nauru"), + ("nb", "Norwegian Bokmål"), + ("nd", "North Ndebele"), + ("ne", "Nepali"), + ("ng", "Ndonga"), + ("nl", "Dutch"), + ("nn", "Norwegian Nynorsk"), + ("no", "Norwegian"), + ("nr", "South Ndebele"), + ("nv", "Navajo"), + ("ny", "Nyanja"), + ("oc", "Occitan"), + ("oj", "Ojibwa"), + ("om", "Oromo"), + ("or", "Oriya"), + ("os", "Ossetian"), + ("pa", "Punjabi"), + ("pi", "Pali"), + ("pl", "Polish"), + ("ps", "Pushto"), + ("pt", "Portuguese"), + ("qu", "Quechua"), + ("rm", "Romansh"), + ("rn", "Rundi"), + ("ro", "Romanian"), + ("ru", "Russian"), + ("rw", "Kinyarwanda"), + ("sa", "Sanskrit"), + ("sc", "Sardinian"), + ("sd", "Sindhi"), + ("se", "Northern Sami"), + ("sg", "Sango"), + ("si", "Sinhala"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("sm", "Samoan"), + ("sn", "Shona"), + ("so", "Somali"), + ("sq", "Albanian"), + ("sr", "Serbian"), + ("ss", "Swati"), + ("st", "Southern Sotho"), + ("su", "Sundanese"), + ("sv", "Swedish"), + ("sw", "Swahili"), + ("ta", "Tamil"), + ("te", "Telugu"), + ("tg", "Tajik"), + ("th", "Thai"), + ("ti", "Tigrinya"), + ("tk", "Turkmen"), + ("tl", "Tagalog"), + ("tn", "Tswana"), + ("to", "Tonga"), + ("tr", "Turkish"), + ("ts", "Tsonga"), + ("tt", "Tatar"), + ("tw", "Twi"), + ("ty", "Tahitian"), + ("ug", "Uighur"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("ve", "Venda"), + ("vi", "Vietnamese"), + ("vo", "Volapük"), + ("wa", "Walloon"), + ("wo", "Wolof"), + ("xh", "Xhosa"), + ("yi", "Yiddish"), + ("yo", "Yoruba"), + ("za", "Zhuang"), + ("zh", "Chinese"), + ("zu", "Zulu"), + # Additional languages with invented codes + ("ax", "Acehnese"), + ("ad", "Adyghe"), + ("ah", "Ahom"), + ("aj", "Ainu"), + ("al", "Akkadian"), + ("ap", "Algonquian languages"), + ("aq", "Ancient Egyptian"), + ("ac", "Ancient Greek"), + ("at", "Aramaic"), + ("ao", "Asturian"), + ("aw", "Awadhi"), + ("bc", "Balochi"), + ("bt", "Batak"), + ("bj", "Beja"), + ("bl", "Belarusian (Taraškievica)"), + ("bp", "Bhojpuri"), + ("by", "Buryat"), + ("cp", "Cantonese"), + ("ct", "Catalan (Valencian)"), + ("cg", "Chichewa"), + ("cl", "Classical Chinese"), + ("cq", "Crimean Tatar"), + ("dx", "Dalmatian"), + ("dm", "Dhivehi"), + ("dg", "Dogri"), + ("dl", "Dungan"), + ("em", "Egyptian Arabic"), + ("ep", "Emilian-Romagnol"), + ("ex", "Extremaduran"), + ("fc", "Franco-Provençal"), + ("fx", "Friulian"), + ("gc", "Gan Chinese"), + ("go", "Gothic"), + ("gr", "Greenlandic"), + ("hk", "Hakka Chinese"), + ("hw", "Hawaiian"), + ("hm", "Hmong"), + ("hx", "Hokkien"), + ("ix", "Ilokano"), + ("iv", "Ingush"), + ("jb", "Javanese (Basa Jawa)"), + ("jp", "Judeo-Persian"), + ("kc", "Kabardian"), + ("kb", "Kabyle"), + ("kp", "Kapampangan"), + ("ku", "Kashubian"), + ("kh", "Khasi"), + ("kx", "Khoisan languages"), + ("kd", "Komi-Permyak"), + ("kz", "Konkani"), + ("lc", "Ladino"), + ("lt", "Latgalian"), + ("lm", "Ligurian"), + ("lp", "Limburgish"), + ("lw", "Livonian"), + ("lh", "Lojban"), + ("lg", "Low German"), + ("ls", "Lower Sorbian"), + ("ma", "Maithili"), + ("mc", "Manchu"), + ("md", "Mandarin Chinese"), + ("mp", "Mapudungun"), + ("mw", "Marwari"), + ("mx", "Min Nan"), + ("mb", "Minangkabau"), + ("mo", "Mohawk"), + ("mu", "Moksha"), + ("me", "Mon"), + ("mv", "Montenegrin"), + ("nc", "Nahuatl"), + ("nm", "Nauruan"), + ("nj", "Navajo"), + ("nx", "Neapolitan"), + ("np", "Nepal Bhasa"), + ("nt", "Northern Sami"), + ("ob", "Occitan (post 1500)"), + ("od", "Odia"), + ("ol", "Old Church Slavonic"), + ("oe", "Old English"), + ("of", "Old French"), + ("oh", "Old High German"), + ("on", "Old Norse"), + ("op", "Old Persian"), + ("ot", "Old Turkic"), + ("ox", "Oromo"), + ("ow", "Ossetian"), + ("pc", "Pangasinan"), + ("px", "Papiamento"), + ("ph", "Pashto"), + ("pd", "Pennsylvania German"), + ("pm", "Piedmontese"), + ("po", "Pontic Greek"), + ("pr", "Proto-Indo-European"), + ("pw", "Punjabi (Western)"), + ("qc", "Quechua (additional dialects)"), + ("rj", "Rajasthani"), + ("rc", "Romani"), + ("rx", "Rusyn"), + ("sx", "Samogitian"), + ("sp", "Santali"), + ("sh", "Scots"), + ("sg", "Scottish Gaelic (additional dialects)"), + ("sb", "Serbo-Croatian"), + ("sf", "Shanghainese"), + ("sv", "Silesian"), + ("sz", "Sicilian"), + ("sy", "Syriac"), + ("tx", "Tamasheq"), + ("tm", "Tamashek"), + ("tc", "Taos"), + ("tb", "Tarifit"), + ("tp", "Tok Pisin"), + ("tu", "Tulu"), + ("ud", "Udmurt"), + ("us", "Upper Sorbian"), + ("uy", "Uyghur (additional script)"), + ("vx", "Venetian"), + ("vp", "Veps"), + ("wc", "Wu Chinese"), + ("ya", "Yakut"), + ("yu", "Yupik languages"), +] diff --git a/speaker/models.py b/speaker/models.py new file mode 100644 index 00000000..a39e454d --- /dev/null +++ b/speaker/models.py @@ -0,0 +1,249 @@ +import re + +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError +from django.core.mail import EmailMultiAlternatives +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.template.loader import render_to_string +from django.urls import reverse + +from portal.models import BaseModel, ChoiceArrayField + +from .constants import ApplicationStatus, Region +from .languages import LANGUAGES + +APPLICATION_STATUS_CHOICES = [ + (ApplicationStatus.PENDING, ApplicationStatus.PENDING), + (ApplicationStatus.APPROVED, ApplicationStatus.APPROVED), + (ApplicationStatus.REJECTED, ApplicationStatus.REJECTED), + (ApplicationStatus.CANCELLED, ApplicationStatus.CANCELLED), +] + +REGION_CHOICES = [ + (Region.NO_REGION, Region.NO_REGION), + (Region.NORTH_AMERICA, Region.NORTH_AMERICA), + (Region.SOUTH_AMERICA, Region.SOUTH_AMERICA), + (Region.EUROPE, Region.EUROPE), + (Region.AFRICA, Region.AFRICA), + (Region.ASIA, Region.ASIA), + (Region.OCEANIA, Region.OCEANIA), +] + + +# class Team(BaseModel): +# short_name = models.CharField("name", max_length=40) +# description = models.CharField("description", max_length=1000) +# team_leads = models.ManyToManyField( +# "speaker.SpeakerProfile", +# verbose_name="team leads", +# related_name="team_leads", +# ) + +# def __str__(self): +# return self.short_name + + +# class Role(BaseModel): +# short_name = models.CharField("name", max_length=40) +# description = models.CharField("description", max_length=1000) + +# def __str__(self): +# return self.short_name + + +class SpeakerProfile(BaseModel): + # user = models.OneToOneField(User, on_delete=models.CASCADE) + # roles = models.ManyToManyField( + # "Role", verbose_name="Roles", related_name="roles", blank=True + # ) + application_status = models.CharField( + max_length=50, + choices=APPLICATION_STATUS_CHOICES, + default=ApplicationStatus.PENDING, + ) + + # social media urls + # github_username = models.CharField(max_length=50, blank=True, null=True) + # discord_username = models.CharField( + # max_length=50, + # blank=False, + # null=False, + # verbose_name="Discord username (required)", + # help_text="Required - Your Discord username for team communication", + # default="", + # ) + # instagram_username = models.CharField(max_length=50, blank=True, null=True) + # bluesky_username = models.CharField(max_length=100, blank=True, null=True) + # mastodon_url = models.CharField(max_length=100, blank=True, null=True) + # x_username = models.CharField(max_length=100, blank=True, null=True) + # linkedin_url = models.CharField(max_length=100, blank=True, null=True) + languages_spoken = ChoiceArrayField( + models.CharField(max_length=32, blank=True, choices=LANGUAGES) + ) + # teams = models.ManyToManyField( + # "speaker.Team", verbose_name="team", related_name="team", blank=True + # ) + # pyladies_chapter = models.CharField(max_length=50, blank=True, null=True) + additional_comments = models.CharField(max_length=1000, blank=True, null=True) + # availability_hours_per_week = models.PositiveIntegerField(default=1) + region = models.CharField( + max_length=50, + choices=REGION_CHOICES, + default=Region.NO_REGION, + ) + + def clean(self): + super().clean() + # self._validate_github_username() + # self._validate_discord_username() + # self._validate_instagram_username() + # self._validate_bluesky_username() + # self._validate_mastodon_url() + # self._validate_x_username() + # self._validate_linkedin_url() + + # def _validate_github_username(self): + # if self.github_username: + # if not re.match( + # r"^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$", + # self.github_username, + # ): + # raise ValidationError( + # { + # "github_username": "GitHub username can only contain alphanumeric characters and hyphens, " + # "cannot start or end with a hyphen, and must be between 1-39 characters." + # } + # ) + + # def _validate_discord_username(self): + # if self.discord_username: + # if not re.match( + # r"^[a-zA-Z0-9](?:[a-zA-Z0-9]|[._-](?=[a-zA-Z0-9])){0,30}[a-zA-Z0-9]$", + # self.discord_username, + # ): + # if len(self.discord_username) < 2 or len(self.discord_username) > 32: + # raise ValidationError( + # { + # "discord_username": "Discord username must be between 2 and 32 characters." + # } + # ) + # else: + # raise ValidationError( + # { + # "discord_username": "Discord username must consist of alphanumeric characters, " + # "dots, underscores, or hyphens, and cannot have consecutive special characters." + # } + # ) + + # def _validate_instagram_username(self): + # if self.instagram_username: + # if not re.match(r"^[a-zA-Z0-9._]{1,30}$", self.instagram_username): + # raise ValidationError( + # { + # "instagram_username": "Instagram username can only contain alphanumeric characters, " + # "periods, and underscores, and must be between 1-30 characters." + # } + # ) + + # def _validate_bluesky_username(self): + # if self.bluesky_username: + # if not re.match( + # r"^[a-zA-Z0-9][a-zA-Z0-9.-]{0,28}[a-zA-Z0-9](\.[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,})?$", + # self.bluesky_username, + # ): + # raise ValidationError( + # { + # "bluesky_username": "Invalid Bluesky username format. " + # "Should be either a simple username or a full handle (e.g., username.bsky.social)." + # } + # ) + + # def _validate_mastodon_url(self): + # if self.mastodon_url: + # mastodon_pattern1 = r"^@[a-zA-Z0-9_]+@[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" # @user@instance.tld + # mastodon_pattern2 = r"^https?://[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/@[a-zA-Z0-9_]+$" # https://instance.tld/@user + + # if not ( + # re.match(mastodon_pattern1, self.mastodon_url) + # or re.match(mastodon_pattern2, self.mastodon_url) + # ): + # raise ValidationError( + # { + # "mastodon_url": "Invalid Mastodon URL format. " + # "Should be either @username@instance.tld or https://instance.tld/@username." + # } + # ) + + # def _validate_x_username(self): + # if self.x_username: + # if not re.match(r"^[a-zA-Z0-9_]{1,15}$", self.x_username): + # raise ValidationError( + # { + # "x_username": "X/Twitter username can only contain alphanumeric characters and underscores, " + # "and must be between 1-15 characters." + # } + # ) + + # def _validate_linkedin_url(self): + # if self.linkedin_url: + # linkedin_pattern = ( + # r"^(https?://)?(www\.)?linkedin\.com/in/[a-zA-Z0-9_-]+/?$" + # ) + + # if not re.match(linkedin_pattern, self.linkedin_url): + # raise ValidationError( + # { + # "linkedin_url": "Invalid LinkedIn URL format. " + # "Should be in the format: linkedin.com/in/username or https://www.linkedin.com/in/username." + # } + # ) + + def __str__(self): + return self.user.username + + def get_absolute_url(self): + return reverse("speaker:speaker_profile_edit", kwargs={"pk": self.pk}) + + +def send_speaker_notification_email(instance, updated=False): + """Send email to the user whenever their speaker profile was updated/created.""" + context = {"profile": instance, "current_site": Site.objects.get_current()} + subject = f"{settings.ACCOUNT_EMAIL_SUBJECT_PREFIX} Speaker Application" + if updated: + context["updated"] = True + subject += " Updated" + else: + subject += " Received" + text_content = render_to_string( + "speaker/email/speaker_profile_email_notification.txt", + context=context, + ) + html_content = render_to_string( + "speaker/email/speaker_profile_email_notification.html", + context=context, + ) + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [instance.user.email], + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + +@receiver(post_save, sender=SpeakerProfile) +def speaker_profile_signal(sender, instance, created, **kwargs): + """Things to do whenever a speaker profile is created or updated. + + Send a notification email to the user to confirm their speaker application status. + """ + if created: + send_speaker_notification_email(instance) + else: + send_speaker_notification_email(instance, updated=True) diff --git a/speaker/tests.py b/speaker/tests.py new file mode 100644 index 00000000..e69de29b diff --git a/speaker/urls.py b/speaker/urls.py new file mode 100644 index 00000000..4e91a0dc --- /dev/null +++ b/speaker/urls.py @@ -0,0 +1,35 @@ +from django.contrib.auth.decorators import login_required +from django.urls import path + +from . import views + +app_name = "speaker" + +urlpatterns = [ + path("", views.index, name="index"), + path( + "list", + login_required(views.SpeakerProfileList.as_view()), + name="speaker_profile_list", + ), + path( + "view//", + login_required(views.SpeakerProfileView.as_view()), + name="speaker_profile_detail", + ), + path( + "new", + login_required(views.SpeakerProfileCreate.as_view()), + name="speaker_profile_new", + ), + path( + "edit/", + login_required(views.SpeakerProfileUpdate.as_view()), + name="speaker_profile_edit", + ), + path( + "delete/", + views.SpeakerProfileDelete.as_view(), + name="speaker_profile_delete", + ), +] diff --git a/speaker/views.py b/speaker/views.py new file mode 100644 index 00000000..f4ab2d0c --- /dev/null +++ b/speaker/views.py @@ -0,0 +1,73 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect, render +from django.urls import reverse_lazy +from django.views.generic import DetailView, ListView +from django.views.generic.edit import CreateView, DeleteView, UpdateView + +from .forms import SpeakerProfileForm +from .models import SpeakerProfile + + +@login_required +def index(request): + context = {} + try: + profile = SpeakerProfile.objects.get(user=request.user) + context["profile_id"] = profile.id + except SpeakerProfile.DoesNotExist: + context["profile_id"] = None + return render(request, "speaker/index.html", context) + + +class SpeakerProfileList(ListView): + model = SpeakerProfile + + +class SpeakerProfileView(DetailView): + model = SpeakerProfile + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + if not self.object or self.object.user != request.user: + return redirect("speaker:index") + return super(SpeakerProfileView, self).get(request, *args, **kwargs) + + +class SpeakerProfileCreate(CreateView): + model = SpeakerProfile + template_name = "speaker/speakerprofile_form.html" + success_url = reverse_lazy("speaker:index") + form_class = SpeakerProfileForm + + def get(self, request, *args, **kwargs): + if SpeakerProfile.objects.filter(user__id=request.user.id).exists(): + return redirect("speaker:index") + return super(SpeakerProfileCreate, self).get(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super(SpeakerProfileCreate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + +class SpeakerProfileUpdate(UpdateView): + model = SpeakerProfile + template_name = "speaker/speakerprofile_form.html" + success_url = reverse_lazy("speaker:index") + form_class = SpeakerProfileForm + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + if not self.object or self.object.user != request.user: + return redirect("speaker:index") + return super(SpeakerProfileUpdate, self).get(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super(SpeakerProfileUpdate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + +class SpeakerProfileDelete(DeleteView): + model = SpeakerProfile + success_url = reverse_lazy("speaker:index") diff --git a/storage_backend/custom_storage.py b/storage_backend/custom_storage.py index 6a81c763..e69de29b 100644 --- a/storage_backend/custom_storage.py +++ b/storage_backend/custom_storage.py @@ -1,8 +0,0 @@ -from storages.backends.s3boto3 import S3Boto3Storage - - -class MediaStorage(S3Boto3Storage): - """Media Storage files""" - - location = "media" - file_overwrite = False diff --git a/templates/portal/base.html b/templates/portal/base.html index 0a860636..910a2958 100644 --- a/templates/portal/base.html +++ b/templates/portal/base.html @@ -58,6 +58,9 @@ + {% endif %}