Skip to content

Commit 6cd1f9c

Browse files
authored
Merge pull request #6 from saaspegasus/pegasus-2026.7.1-1785249993.303172
Pegasus update to version 2026.7.1
2 parents adc6d75 + eafd33a commit 6cd1f9c

15 files changed

Lines changed: 93 additions & 46 deletions

File tree

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,18 @@ make uv run 'pegasus startapp <app_name> <Model1> <Model2Name>' # Start a new D
138138
- Try to use type hints in new code. However, strict type-checking is not enforced and you can leave them out if it's burdensome.
139139
There is no need to add type hints to existing code if it does not already use them.
140140

141+
### Type annotation conventions
142+
143+
Type checking runs with mypy + django-stubs (`make type-check`). Conventions:
144+
145+
- **Routed views leave `request` unannotated** (all of them, even views that don't touch `request.user`).
146+
mypy can't see the guarantees made by `@login_required` and similar decorators, so annotating
147+
`request: HttpRequest` makes accesses like `request.user.<related>` fail type checking.
148+
Everything that isn't a routed view — middleware, context processors, signal handlers, forms,
149+
internal helpers — should annotate `request: HttpRequest` normally.
150+
- **In DRF views where a permission class guarantees authentication**, get the typed user via
151+
`apps.users.helpers.get_authenticated_user(request)` rather than casting `request.user` inline.
152+
141153
### Python 3.14 syntax notes
142154

143155
- **Unparenthesized `except` with multiple exception types is valid** (PEP 758, Python 3.14+).

apps/users/apps.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ class UserConfig(AppConfig):
66
label = "users"
77
default_auto_field = "django.db.models.BigAutoField"
88

9-
def ready(self):
9+
def ready(self) -> None:
1010
from . import signals # noqa F401

apps/users/forms.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ class TurnstileSignupForm(SignupForm):
2020

2121
turnstile_token = forms.CharField(widget=forms.HiddenInput(), required=False)
2222

23-
def clean_turnstile_token(self):
23+
def clean_turnstile_token(self) -> str | None:
2424
if not settings.TURNSTILE_SECRET:
2525
logging.info("No turnstile secret found, not checking captcha")
26-
return
26+
return None
2727

2828
turnstile_token = self.cleaned_data.get("turnstile_token", None)
2929
if not turnstile_token:

apps/users/helpers.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,42 @@
11
import os
2+
from typing import TYPE_CHECKING, cast
23

34
from allauth.account import app_settings
45
from allauth.account.models import EmailAddress
56
from django.conf import settings
67
from django.core.exceptions import ValidationError
8+
from django.http import HttpRequest
79
from django.utils.translation import gettext as _
810

11+
if TYPE_CHECKING:
12+
from apps.users.models import CustomUser
913

10-
def require_email_confirmation():
14+
15+
def require_email_confirmation() -> bool:
1116
return settings.ACCOUNT_EMAIL_VERIFICATION == app_settings.EmailVerificationMethod.MANDATORY
1217

1318

14-
def user_has_confirmed_email_address(user, email):
19+
def user_has_confirmed_email_address(user: CustomUser, email: str) -> bool:
1520
try:
1621
email_obj = EmailAddress.objects.get_for_user(user, email)
1722
return email_obj.verified
1823
except EmailAddress.DoesNotExist:
1924
return False
2025

2126

22-
def validate_profile_picture(value):
27+
def get_authenticated_user(request: HttpRequest) -> CustomUser:
28+
"""
29+
Get the authenticated user, resolving API-key auth if needed.
30+
31+
Callers must guarantee authentication (e.g. via permission classes or login_required).
32+
"""
33+
if request.user.is_anonymous:
34+
raise ValueError("get_authenticated_user requires an authenticated user")
35+
else:
36+
return cast("CustomUser", request.user)
37+
38+
39+
def validate_profile_picture(value) -> None:
2340
valid_extensions = {
2441
".jpg",
2542
".jpeg",

apps/users/management/commands/promote_user_to_superuser.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
from django.core.management.base import BaseCommand, CommandError
1+
from typing import Any
2+
3+
from django.core.management.base import BaseCommand, CommandError, CommandParser
24

35
from apps.users.models import CustomUser
46

57

68
class Command(BaseCommand):
79
help = "Promotes the given user to a superuser and provides admin access."
810

9-
def add_arguments(self, parser):
11+
def add_arguments(self, parser: CommandParser) -> None:
1012
parser.add_argument("username", type=str)
1113

12-
def handle(self, username, **options):
14+
def handle(self, username: str, **options: Any) -> None:
1315
try:
1416
user = CustomUser.objects.get(username=username)
1517
except CustomUser.DoesNotExist:

apps/users/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from apps.users.helpers import validate_profile_picture
1010

1111

12-
def _get_avatar_filename(instance, filename):
12+
def _get_avatar_filename(instance: CustomUser, filename: str) -> str:
1313
"""Use random filename prevent overwriting existing files & to fix caching issues."""
1414
return f"profile-pictures/{uuid.uuid4()}.{filename.split('.')[-1]}"
1515

@@ -21,7 +21,7 @@ class CustomUser(AbstractUser):
2121

2222
avatar = models.FileField(upload_to=_get_avatar_filename, blank=True, validators=[validate_profile_picture])
2323

24-
def __str__(self):
24+
def __str__(self) -> str:
2525
return f"{self.get_full_name()} <{self.email or self.username}>"
2626

2727
def get_display_name(self) -> str:
@@ -42,5 +42,5 @@ def gravatar_id(self) -> str:
4242
return hashlib.md5(self.email.lower().strip().encode("utf-8")).hexdigest()
4343

4444
@cached_property
45-
def has_verified_email(self):
45+
def has_verified_email(self) -> bool:
4646
return EmailAddress.objects.filter(user=self, verified=True).exists()

apps/users/signals.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
1+
from typing import Any
2+
3+
from allauth.account.models import EmailAddress
14
from allauth.account.signals import email_confirmed, user_signed_up
25
from django.conf import settings
36
from django.core.files.storage import default_storage
47
from django.core.mail import mail_admins
58
from django.db.models.signals import post_delete, pre_save
69
from django.dispatch import receiver
10+
from django.http import HttpRequest
711

812
from apps.users.models import CustomUser
913

1014

1115
@receiver(user_signed_up)
12-
def handle_sign_up(request, user, **kwargs):
16+
def handle_sign_up(request: HttpRequest, user: CustomUser, **kwargs: Any) -> None:
1317
# customize this function to do custom logic on sign up, e.g. send a welcome email
1418
# or subscribe them to your mailing list.
1519
# This example notifies the admins, in case you want to keep track of sign ups
1620
_notify_admins_of_signup(user)
1721

1822

1923
@receiver(email_confirmed)
20-
def update_user_email(sender, request, email_address, **kwargs):
24+
def update_user_email(sender: Any, request: HttpRequest, email_address: EmailAddress, **kwargs: Any) -> None:
2125
"""
2226
When an email address is confirmed make it the primary email.
2327
"""
@@ -26,7 +30,7 @@ def update_user_email(sender, request, email_address, **kwargs):
2630
email_address.set_as_primary()
2731

2832

29-
def _notify_admins_of_signup(user):
33+
def _notify_admins_of_signup(user: CustomUser) -> None:
3034
mail_admins(
3135
f"Yowsers, someone signed up for {settings.PROJECT_METADATA['NAME']}!",
3236
f"Email: {user.email}",
@@ -35,20 +39,20 @@ def _notify_admins_of_signup(user):
3539

3640

3741
@receiver(pre_save, sender=CustomUser)
38-
def remove_old_profile_picture_on_change(sender, instance, **kwargs):
42+
def remove_old_profile_picture_on_change(sender: type[CustomUser], instance: CustomUser, **kwargs: Any) -> None:
3943
if not instance.pk:
40-
return False
44+
return
4145

4246
try:
4347
old_file = sender.objects.get(pk=instance.pk).avatar
4448
except sender.DoesNotExist:
45-
return False
49+
return
4650

47-
if old_file and old_file.name != instance.avatar.name and default_storage.exists(old_file.name):
51+
if old_file.name and old_file.name != instance.avatar.name and default_storage.exists(old_file.name):
4852
default_storage.delete(old_file.name)
4953

5054

5155
@receiver(post_delete, sender=CustomUser)
52-
def remove_profile_picture_on_delete(sender, instance, **kwargs):
53-
if instance.avatar and default_storage.exists(instance.avatar.name):
56+
def remove_profile_picture_on_delete(sender: type[CustomUser], instance: CustomUser, **kwargs: Any) -> None:
57+
if instance.avatar.name and default_storage.exists(instance.avatar.name):
5458
default_storage.delete(instance.avatar.name)

apps/users/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313

1414
@login_required
15-
def profile(request):
15+
def profile(request) -> HttpResponse:
1616
if request.method == "POST":
1717
form = CustomUserChangeForm(request.POST, instance=request.user)
1818
if form.is_valid():
@@ -57,7 +57,7 @@ def profile(request):
5757

5858
@login_required
5959
@require_POST
60-
def upload_profile_image(request):
60+
def upload_profile_image(request) -> HttpResponse:
6161
user = request.user
6262
form = UploadAvatarForm(request.POST, request.FILES)
6363
if form.is_valid():

apps/web/context_processors.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from copy import copy
2+
from typing import Any
23

34
from django.conf import settings
5+
from django.http import HttpRequest
46

57
from .meta import absolute_url, get_server_root
68

79

8-
def project_meta(request):
10+
def project_meta(request: HttpRequest) -> dict[str, Any]:
911
# modify these values as needed and add whatever else you want globally available here
1012
project_data = copy(settings.PROJECT_METADATA)
1113
project_data["TITLE"] = "{} | {}".format(project_data["NAME"], project_data["DESCRIPTION"])
@@ -20,7 +22,7 @@ def project_meta(request):
2022
}
2123

2224

23-
def csrf_settings(request):
25+
def csrf_settings(request: HttpRequest) -> dict[str, str]:
2426
"""
2527
Exposes the configured CSRF cookie name to templates so front-end JS can
2628
read the correct cookie regardless of how CSRF_COOKIE_NAME is set. See
@@ -31,7 +33,7 @@ def csrf_settings(request):
3133
}
3234

3335

34-
def google_analytics_id(request):
36+
def google_analytics_id(request: HttpRequest) -> dict[str, str]:
3537
"""
3638
Adds google analytics id to all requests
3739
"""

apps/web/management/commands/bootstrap_celery_tasks.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1+
from typing import Any
2+
13
from django.conf import settings
2-
from django.core.management.base import BaseCommand
4+
from django.core.management.base import BaseCommand, CommandParser
35
from django_celery_beat.models import PeriodicTask
46
from django_celery_beat.schedulers import ModelEntry
57

68

79
class Command(BaseCommand):
810
help = "Bootstrap Celery periodic tasks for the environment."
911

10-
def add_arguments(self, parser):
12+
def add_arguments(self, parser: CommandParser) -> None:
1113
parser.add_argument(
1214
"--remove-stale",
1315
action="store_true",
1416
help="Remove tasks that are not defined in this command",
1517
)
1618

17-
def handle(self, *args, **options):
19+
def handle(self, *args: Any, **options: Any) -> None:
1820
created_task_names = []
1921
for task_name, task_config in settings.SCHEDULED_TASKS.items():
2022
schedule_spec = task_config.pop("schedule")

0 commit comments

Comments
 (0)