Migrating from Pengin-Pi-2
Don't upgrade a Pengin-Pi-2 installation. Start a new Pengin-Pi-3 project and port the applications you want into it.
There is no upgrade path, and there won't be one.
Why there's no upgrade
Pengin-Pi-2 was never finished and never released. It has no package and no release candidate, and it is obsolete and unsupported.
The version number undersells the gap. Pengin-Pi-2 was a completed port of Pengin-Pi-1 from Flask to Django — the same application on a new framework. Pengin-Pi-3 is a different application. A great deal of time passed between them, and the codebases diverged far enough that calling the result "2.x" would have been misleading. It was branded 3 for that reason.
What changed:
- Core. Pengin-Pi-3 ships a core —
main/,util/, andtemplates/— and no business applications. Pengin-Pi-2 shipped the applications as part of the product. - Auth. Pengin-Pi-2 scattered permission and profile logic across apps.
Pengin-Pi-3 centralizes the framework in
main/auth/. See RBAC. - Content. Pengin-Pi-3's Slug CMS, dynamic content types, and wiki have no Pengin-Pi-2 equivalent.
- Front end. Pengin-Pi-2 templates are not Bootstrap and the markup is dated. Pengin-Pi-3 is Bootstrap 5 throughout, with a fixed set of layouts and block names.
Some project structure and app code will look familiar. That's the part you can reuse. Everything around it is new.
Where code belongs
Before porting anything, know where it's going. Pengin-Pi-3 has three homes for code, and the rule for each is strict.
Your app — the app's own features. Models, views, forms, templates, URLs, and app-specific permission rules all stay in the app's own package. This is the default, and a ported app lands here intact.
main/ — only what is truly universal: something the core's own
components use and that other apps can build on. The user model, the RBAC
framework, the Slug CMS, history auditing. If only your app needs it, it does
not belong in main.
util/ — middleware, custom libraries, helper scripts, helper functions,
and utilities that main and apps both subscribe to. Generic by definition,
and deliberately free of auth logic.
A useful test for main versus your app: would the core, or an unrelated
app, call this? If not, keep it in your app. A test for util/: is it
generic enough that an app with nothing in common with yours could use it
unchanged?
Contributions of new util/ modules are welcome when they're genuinely
reusable — a helper you wrote while porting may be one. See
Contributing.
What about Tobu Pengin's private branches?
Tobu Pengin holds application branches — on the internal Pengin Open Source GitLab — that started from the same Pengin-Pi-2 code. They are not the same code anymore. Those branches have been significantly reworked to fit Pengin-Pi-3's architecture and are many revisions ahead of their Pengin-Pi-2 ancestors.
If an app you need exists there, the ported version is a better starting point than the Pengin-Pi-2 original. Ask at support@tobupengin.com.
The recommended approach
- Start a fresh Pengin-Pi-3 project. Follow Install. Get it running clean before you bring anything in.
- Choose only the apps you need, from Pengin-Pi-2 or any other Django codebase.
- Port them one at a time, following the steps below, and get each working before starting the next.
Porting one app into a working base tells you exactly what broke. Porting five into a half-configured base tells you nothing.
Porting an app
1. Copy the app in
Copy the app directory to the project root, alongside main/ and util/.
It keeps its own package, its own models, and its own migrations directory —
that is the normal shape of a Pengin-Pi-3 app, not a special case.
Don't dissolve the app into main. Its features stay in the app. Only
move something into main or util/ if it passes the tests above — and in a
first port, almost nothing will.
2. Register it in settings
In main/settings.py:
INSTALLED_APPS = [
...
'macros',
# Add your apps below here:
'main',
'jobs',
'applications',
]
3. Wire its URLs — above the wiki routes
In main/urls.py:
path('jobs/', include('jobs.urls')),
path('applications/', include('applications.urls')),
Placement matters, and this one will bite. Your includes must sit above the wiki routes, not just above the slug catch-all.
The wiki registers suffix patterns that match any path ending in create/
or edit/:
path('<path:base_path>/create/', WikiPageCreateView.as_view(), ...)
path('<path:base_path>/edit/', WikiPageEditView.as_view(), ...)
Pengin-Pi-2 apps are full of routes that end exactly that way —
applications/<uuid>/application/create/, jobs/<uuid>/edit/. Register the
app below those patterns and every one of those routes is captured by the
wiki, which can't find a wiki page there and returns a 404. Nothing
errors; the pages just don't exist.
Put app includes near the top of urlpatterns, after admin/, and you
avoid it entirely. See Wiki Module.
4. Discard the old migrations
Delete the app's migrations/ contents except __init__.py, then
regenerate:
python manage.py makemigrations jobs applications
python manage.py migrate
Pengin-Pi-2 migrations reference Pengin-Pi-2's user model, keys, and dependency graph. Replaying them against Pengin-Pi-3 is more work than starting clean, and you are starting clean anyway. If you need Pengin-Pi-2 data, move it with a script after the schema exists — don't try to carry the migration history.
5. Bring the models up to standard
The models stay in the app. What changes is what they build on.
UUID primary keys. Every Pengin-Pi-3 model uses one:
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
Fix user defaults. A common Pengin-Pi-2 pattern:
user = models.ForeignKey(settings.AUTH_USER_MODEL, ...,
default=settings.DEFAULT_USER_ID)
DEFAULT_USER_ID is 1, and Pengin-Pi-3's User has a UUID primary key.
That default can never resolve. Remove it and set the user explicitly in the
view.
Add history where edits should be audited. HistoryMixin and
AbstractHistory are universal — that's why they're in main — and your
app's models subscribe to them:
from main.models.mixins import HistoryMixin, AbstractHistory
The paired history model lives in your app, next to the model it tracks. See History and Auditing.
Opt into the sitemap if the model has public pages — subclass
SitemapEntry and implement get_absolute_url(). The sitemap finds
SitemapEntry subclasses in any installed app, so nothing needs registering
in main. See SEO and Sitemaps.
6. Build permissions on main/auth/
The RBAC framework is universal and lives in main/auth/. Your app's
rules stay in your app.
Delete permission logic the app reimplements from scratch — group membership
queries, role lookups, private is_admin helpers — and build on the
framework's primitives instead:
# applications/permissions.py
from main.auth import is_root, is_executive_manager, is_manager_of_group
def can_review_application(user, application):
if is_root(user) or is_executive_manager(user):
return True
return is_manager_of_group(user, application.job.department)
That function belongs in the app, because only the app knows what an
application is. What it must not do is re-derive "is this person a manager"
itself — that question has one answer, and it comes from main.auth.
Views use the shared guards directly:
from main.auth import StaffRequiredMixin, LoginAndValidationRequiredMixin
Pengin-Pi-2 apps commonly read request.user.groups directly. That still
works, but only because sync_team_role_groups() keeps group membership
aligned with team role assignments — so if the app assigns roles anywhere,
it must call the sync. See RBAC.
7. Add the security mixins
Pengin-Pi-3 rate-limits every POST that matters and reCAPTCHA-gates
anonymous-facing forms. Both live in util/security/ for your app to
subscribe to:
from util.security.ratelimit import RateLimitedPostMixin
from util.security.recaptcha import RecaptchaRequiredMixin
class ApplyView(RateLimitedPostMixin, RecaptchaRequiredMixin, View):
ratelimit_rate = '5/m'
Pengin-Pi-2 views have none of this. Add it rather than assuming it's inherited. See Mixins.
8. Port the templates
The largest job, and the one most likely to be skipped. The templates stay
in the app's own templates/ directory; what changes is what they extend.
Extend the right base. Pengin-Pi-2 templates extend base.html and fill
{% block content %}. Neither exists in Pengin-Pi-3.
{% extends "base.html" %} → {% extends "layout.html" %}
{% block content %} → {% block pageContent %}
layout.html defines these blocks:
pageContent— the page bodypageHeader— hero or header areapage_title— the document titletags,meta_description— head metadatajsonld— structured datacaptchaScript— reCAPTCHA loader, for formscontact— footer contact sectionstaff_toolbar— reserved for staff edit affordances
Or extend one of the shipped layouts, which already arrange pageContent
into a structure:
layouts/centered-card-form.html— single forms, login-stylelayouts/table-list.html— list and index pageslayouts/two-col_article-left_sidebar-right.html— detail pageslayouts/two-col_feed-left_filters-right.html— filterable feedslayouts/hero-feature-grid.html,layouts/hero-video.html— landing pageslayout/no_contact.html— the base layout without the contact footer
These expose their own named sub-blocks — middle_article, left_col,
right_col, top_left, top_right and others. Read the layout file to see
which it defines.
Remove Flask leftovers. Pengin-Pi-2 descends from a Flask codebase, and Jinja syntax occasionally survived the port:
{{ url_for('static', filename='icons/menu.svg') }} → {% static 'icons/menu.svg' %}
This raises TemplateSyntaxError: Could not parse the remainder — often from
a parent template or an include rather than the file you're looking at.
Every template using {% static %} also needs {% load static %} at the
top.
Clear the fixed navbar. Content rendered directly under the sticky navbar
gets clipped. Wrap the page in pt-5 mt-4 or use a shipped layout, which
handles it.
9. Convert the markup to Bootstrap
Pengin-Pi-2 markup isn't Bootstrap, and dropping it into a Bootstrap 5 layout produces pages that work but look foreign. Converting it by hand is tedious and mostly mechanical, which makes it a good job for an LLM.
A prompt that works well:
Convert this Django template to Bootstrap 5. Extend
layout.htmland put the content in{% block pageContent %}. Preserve every Django template tag, variable,{% url %},{% csrf_token %}, and form field exactly — change only the HTML structure and classes. Use Bootstrap utility classes rather than custom CSS. Replace anyurl_for(...)with{% static %}and add{% load static %}.
Paste in one template at a time, along with the layout you're extending if
you want it to fill that layout's sub-blocks. Review the output for dropped
template tags — that is the characteristic failure, and it breaks silently
at render time rather than at load. Diffing the old and new files for {%
and {{ is a quick check.
10. Look for anything worth promoting
With the app working, read back through what you ported. A date-formatting
helper, a file-validation routine, a generic middleware — something that
isn't really about this app. If another app could use it unchanged, it's a
candidate for util/, and a pull request is welcome.
Promote only what passes the tests in Where code belongs.
Moving app code into main because it's convenient is the mistake this
structure exists to prevent.
11. Verify
python manage.py check
python manage.py makemigrations --check --dry-run
python manage.py runserver
Then, for each route: does it resolve to your view rather than the wiki or the slug catch-all, does it render inside the layout, and does a non-authorized user get turned away?
python manage.py shell -c "
from django.urls import resolve
print(resolve('/applications/<some-uuid>/application/create/').func)
"
If that prints a wiki view, go back to step 3.
Checklist
- [ ] Fresh Pengin-Pi-3 project, running clean first
- [ ] App directory copied to project root, kept as its own app
- [ ] Added to
INSTALLED_APPS - [ ] URLs included above the wiki routes
- [ ] Old migrations discarded, new ones generated
- [ ] UUID primary keys
- [ ]
DEFAULT_USER_IDdefaults removed - [ ]
HistoryMixinwhere edits should be audited, history model in the app - [ ]
SitemapEntryon models with public pages - [ ] App permission rules in the app, built on
main.authprimitives - [ ] Rate limiting and reCAPTCHA mixins from
util/security/ - [ ] Templates extend
layout.htmlor a shipped layout - [ ]
{% block content %}→{% block pageContent %} - [ ] No
url_forleft anywhere, including includes and parents - [ ] Markup converted to Bootstrap 5
- [ ] Nothing moved into
mainunless it's truly universal - [ ] Every route verified with
resolve()
Next
- Install — starting the fresh project
- Architecture — what Pengin-Pi-3 expects of your code
- RBAC — the framework your app's permission rules build on
- Contributing — conventions, and submitting
util/modules
Contents
- Migrating from Pengin-Pi-2
- Why there's no upgrade
- Where code belongs
- What about Tobu Pengin's private branches?
- The recommended approach
- Porting an app
- 1. Copy the app in
- 2. Register it in settings
- 3. Wire its URLs — above the wiki routes
- 4. Discard the old migrations
- 5. Bring the models up to standard
- 6. Build permissions on `main/auth/`
- 7. Add the security mixins
- 8. Port the templates
- 9. Convert the markup to Bootstrap
- 10. Look for anything worth promoting
- 11. Verify
- Checklist
- Next
Pages Here
No sub-pages yet.
Page Info
Wiki: Docs
Created on Sep 21, 2026 by Stuart Anderson
Maintainers
| Editor | Last Activity |
|---|---|
| Stuart Anderson creator | Sep 21, 2026 |