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/, and templates/ — 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.


  1. Start a fresh Pengin-Pi-3 project. Follow Install. Get it running clean before you bring anything in.
  2. Choose only the apps you need, from Pengin-Pi-2 or any other Django codebase.
  3. 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 body
  • pageHeader — hero or header area
  • page_title — the document title
  • tags, meta_description — head metadata
  • jsonld — structured data
  • captchaScript — reCAPTCHA loader, for forms
  • contact — footer contact section
  • staff_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-style
  • layouts/table-list.html — list and index pages
  • layouts/two-col_article-left_sidebar-right.html — detail pages
  • layouts/two-col_feed-left_filters-right.html — filterable feeds
  • layouts/hero-feature-grid.html, layouts/hero-video.html — landing pages
  • layout/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.html and 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 any url_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_ID defaults removed
  • [ ] HistoryMixin where edits should be audited, history model in the app
  • [ ] SitemapEntry on models with public pages
  • [ ] App permission rules in the app, built on main.auth primitives
  • [ ] Rate limiting and reCAPTCHA mixins from util/security/
  • [ ] Templates extend layout.html or a shipped layout
  • [ ] {% block content %}{% block pageContent %}
  • [ ] No url_for left anywhere, including includes and parents
  • [ ] Markup converted to Bootstrap 5
  • [ ] Nothing moved into main unless 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