Skip to content

Commit 721b138

Browse files
authored
Merge pull request #4 from saaspegasus/pegasus-2026.6.2.5-1784730718.499782
Pegasus update to version 2026.6.2.5
2 parents ed7ab79 + da06d1c commit 721b138

7 files changed

Lines changed: 146 additions & 7 deletions

File tree

.claude/skills/resolve-pegasus-conflicts/SKILL.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@ First, determine what the user needs help with by checking their current state:
2424
The user has already run `git merge <main branch>` and has conflicts to resolve. Help them resolve the conflicts using these strategies:
2525

2626
#### Database Migrations
27-
- **Strategy**: Discard Pegasus migration changes, keep the user's changes
28-
- **Reason**: Migration files should be regenerated, not merged
29-
- **Action**: For conflicted migration files, accept theirs (the user's version from main), then run `./manage.py makemigrations` after all conflicts are resolved
30-
- **Git command**: `git checkout --theirs <migration-file>` for each conflicted migration
27+
28+
**Principle**: When main/local and Pegasus disagree about a migration, main/local wins. Main's migration files typically reflect what the local DB (and prod) have already applied, so that history is the baseline you protect. When a Pegasus migration *conflicts with or duplicates* something main has already applied, you should drop Pegasus's version in favour of main's, then run `./manage.py makemigrations` to re-express any genuine change as a clean forward diff (the change lives in the merged models, so makemigrations re-derives it).
29+
30+
- **On a conflict**: For conflicted migration files, accept the user's version from main with `git checkout --theirs <migration-file>`, then run `./manage.py makemigrations` after all conflicts are resolved.
31+
- **Watch for silent (conflict-free) migration changes — this is easy to miss**: Pegasus regenerates its own auto-generated migrations and sometimes **renames** them (e.g. `0002_customuser_language...``0002_customuser_customer...`). A rename is a delete-on-one-side + add-on-the-other, which git merges with **no conflict markers** — so it slips through silently and leaves you with a duplicate/competing migration the conflict step never flagged. After merging, always diff the migration dirs against main to catch this:
32+
```
33+
git diff --name-status main HEAD -- '*/migrations/*.py'
34+
```
35+
Treat any `R` (rename) the same as a conflict: restore main's file (`git checkout main -- <file>`), delete Pegasus's regenerated copy (`git rm <file>`), then `./manage.py makemigrations` to regenerate the forward diff cleanly. Brand-new app migrations (all `A`, e.g. a freshly added `ecommerce` app) are fine — keep those as-is.
3136
- **djstripe migration references**: If djstripe was upgraded, check if app migrations reference old djstripe migrations that no longer exist (see the "djstripe 2.10 upgrade" section below).
3237

3338
#### Dependency Lock Files (uv.lock, requirements.txt, package-lock.json)
@@ -75,12 +80,28 @@ After all conflicts are resolved and the merge is complete, run the verification
7580

7681
1. **Frontend install + build**: `npm install && npm run build`
7782
2. **Python dependency sync**: `uv sync`
78-
3. **Migrations**: `./manage.py makemigrations` then `./manage.py migrate`
83+
3. **Migrations**: `./manage.py makemigrations` then `./manage.py migrate`. If `migrate` fails partway, see "Recovering from a partial migration" below.
7984
4. **Tests**: `./manage.py test`
8085
5. **Ruff format + lint**: `make ruff` (auto-formats and auto-fixes lint issues)
8186

8287
Commit any pending changes produced by the steps above (e.g. new migrations, formatting fixes) with a clear message. Docker users can substitute `make upgrade` for the build/migrate steps.
8388

89+
### Recovering from a partial migration
90+
91+
`migrate` commits each migration separately, so a failure partway through can leave earlier migrations applied while a later one fails — the DB is now in a half-migrated state that no longer matches its starting baseline. The local dev DB is typically kept at the same migration state as production, so it matters that you put it back. For Pegasus's schema-only, reversible migrations this is straightforward to undo.
92+
93+
1. **See what actually applied — check, don't theorize.** Surprising schema (tables/columns you didn't expect) is far more likely to be something *you just applied* than pre-existing cruft. Confirm with timestamps:
94+
```
95+
./manage.py dbshell -- -c "SELECT app, name, applied FROM django_migrations ORDER BY applied DESC LIMIT 20;"
96+
```
97+
The recent timestamps are what this run applied.
98+
2. **Reverse them** back to the last good state, in reverse-dependency order (unapply the app that *depends on* another before the one it depends on):
99+
```
100+
./manage.py migrate <app> <last_good_migration> # or `zero` to unapply an app entirely
101+
```
102+
3. **If the reverse errors loudly** (e.g. `IrreversibleError` — a data migration with no reverse defined), stop and raise to a human rather than forcing it. Don't pre-gate on `atomic = False` or side effects; just attempt the reverse and let it fail loudly if it can't. (If a forward op already dropped data, that loss happened on apply — reversing won't recover it, and that's also a human escalation.)
103+
4. Once back at a clean baseline, fix the offending migration (see "Database Migrations" above — usually a Pegasus rename/duplicate), then re-run `./manage.py migrate` and confirm the full chain applies cleanly from the baseline.
104+
84105
### Pushing
85106

86107
If everything above passed, the default is to push so the user can open a PR:

apps/users/migrations/0002_alter_customuser_avatar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 6.0.5 on 2026-06-22 08:29
1+
# Generated by Django 6.0.5 on 2026-07-22 14:31
22

33
import apps.users.helpers
44
import apps.users.models

apps/users/tests/test_helpers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from django.core.exceptions import ValidationError
2+
from django.core.files.uploadedfile import SimpleUploadedFile
3+
from django.test import SimpleTestCase
4+
5+
from apps.users.helpers import validate_profile_picture
6+
7+
MAX_FILE_SIZE = 5242880 # 5 MB, mirrors the limit in validate_profile_picture
8+
9+
10+
def _image_file(name, size=10):
11+
return SimpleUploadedFile(name, b"x" * size)
12+
13+
14+
class ValidateProfilePictureTest(SimpleTestCase):
15+
def test_valid_extensions(self):
16+
for name in ["photo.jpg", "photo.jpeg", "photo.png", "photo.tif", "photo.tiff", "photo.webp", "photo.bmp"]:
17+
with self.subTest(name=name):
18+
validate_profile_picture(_image_file(name)) # should not raise
19+
20+
def test_extension_check_is_case_insensitive(self):
21+
validate_profile_picture(_image_file("photo.JPG"))
22+
validate_profile_picture(_image_file("photo.PnG"))
23+
24+
def test_invalid_extensions(self):
25+
for name in ["photo.gif", "photo.svg", "script.exe", "photo", "photo.jpg.txt"]:
26+
with self.subTest(name=name), self.assertRaises(ValidationError):
27+
validate_profile_picture(_image_file(name))
28+
29+
def test_file_at_size_limit_is_allowed(self):
30+
validate_profile_picture(_image_file("photo.jpg", size=MAX_FILE_SIZE))
31+
32+
def test_file_over_size_limit_is_rejected(self):
33+
with self.assertRaises(ValidationError):
34+
validate_profile_picture(_image_file("photo.jpg", size=MAX_FILE_SIZE + 1))
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from allauth.account.models import EmailAddress
2+
from django.test import SimpleTestCase, TestCase
3+
4+
from apps.users.models import CustomUser
5+
6+
7+
class DisplayNameTest(SimpleTestCase):
8+
def test_uses_full_name_when_set(self):
9+
user = CustomUser(first_name="Ada", last_name="Lovelace", email="ada@example.com")
10+
self.assertEqual(user.get_display_name(), "Ada Lovelace")
11+
12+
def test_falls_back_to_email(self):
13+
user = CustomUser(email="ada@example.com", username="ada-username")
14+
self.assertEqual(user.get_display_name(), "ada@example.com")
15+
16+
def test_falls_back_to_username_without_email(self):
17+
user = CustomUser(username="ada-username")
18+
self.assertEqual(user.get_display_name(), "ada-username")
19+
20+
def test_str_includes_name_and_email(self):
21+
user = CustomUser(first_name="Ada", last_name="Lovelace", email="ada@example.com")
22+
self.assertEqual(str(user), "Ada Lovelace <ada@example.com>")
23+
24+
25+
class GravatarTest(SimpleTestCase):
26+
def test_gravatar_id_is_md5_of_email(self):
27+
user = CustomUser(email="test@example.com")
28+
self.assertEqual(user.gravatar_id, "55502f40dc8b7c769880b10874abc9d0")
29+
30+
def test_gravatar_id_normalizes_case_and_whitespace(self):
31+
self.assertEqual(
32+
CustomUser(email=" TEST@Example.com ").gravatar_id,
33+
CustomUser(email="test@example.com").gravatar_id,
34+
)
35+
36+
def test_avatar_url_falls_back_to_gravatar(self):
37+
user = CustomUser(email="test@example.com")
38+
self.assertEqual(
39+
user.avatar_url,
40+
"https://www.gravatar.com/avatar/55502f40dc8b7c769880b10874abc9d0?s=128&d=identicon",
41+
)
42+
43+
44+
class HasVerifiedEmailTest(TestCase):
45+
def test_verified_email(self):
46+
user = CustomUser.objects.create(username="a@example.com", email="a@example.com")
47+
EmailAddress.objects.create(user=user, email="a@example.com", verified=True, primary=True)
48+
self.assertTrue(user.has_verified_email)
49+
50+
def test_unverified_email(self):
51+
user = CustomUser.objects.create(username="b@example.com", email="b@example.com")
52+
EmailAddress.objects.create(user=user, email="b@example.com", verified=False, primary=True)
53+
self.assertFalse(user.has_verified_email)
54+
55+
def test_no_email_records(self):
56+
user = CustomUser.objects.create(username="c@example.com", email="c@example.com")
57+
self.assertFalse(user.has_verified_email)

apps/utils/tests/__init__.py

Whitespace-only changes.

apps/web/tests/test_meta.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from django.contrib.sites.models import Site
2+
from django.test import TestCase
3+
4+
from apps.web.meta import absolute_url, get_protocol, get_server_root
5+
6+
7+
class MetaUrlTest(TestCase):
8+
def setUp(self):
9+
site = Site.objects.get_current()
10+
site.domain = "example.com"
11+
site.save()
12+
Site.objects.clear_cache()
13+
14+
def tearDown(self):
15+
Site.objects.clear_cache()
16+
17+
def test_get_protocol(self):
18+
self.assertEqual(get_protocol(is_secure=False), "http")
19+
self.assertEqual(get_protocol(is_secure=True), "https")
20+
21+
def test_get_server_root(self):
22+
self.assertEqual(get_server_root(is_secure=True), "https://example.com")
23+
self.assertEqual(get_server_root(is_secure=False), "http://example.com")
24+
25+
def test_absolute_url(self):
26+
self.assertEqual(absolute_url("/dashboard/", is_secure=True), "https://example.com/dashboard/")
27+
self.assertEqual(absolute_url("/dashboard/", is_secure=False), "http://example.com/dashboard/")

pegasus-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ cli:
66
template_directory: templates
77
use_teams: false
88
default_context:
9-
_pegasus_version: 2026.6.2
9+
_pegasus_version: 2026.6.2.5
1010
api_framework: drf
1111
author_name: Cory Zue
1212
bundler: vite

0 commit comments

Comments
 (0)