Mini-sites — Developer Reference
Model hierarchy
Website (owner FK → User)
├── WebsiteSection (sort_order)
│ └── SectionAsset (parent_page FK self, depth limit 4)
│ └── PageBlock (block_type CharField, content JSONField, sort_order)
├── WebsiteLogo (image, sort_order, is_active)
└── WebsiteRevision (snapshot JSONField, note, created_at)
ContactMessage (website FK)
SectionAssetLog (asset FK, user FK, status, message, filename, created_at)
Key Website model fields
slug— internal slug, scoped per owner (not globally unique)public_slug— globally unique, required for publishing; cleared to unpublishis_published(bool) +publish_visibility(PUBLIC/UNLISTED/GROUP)allowed_groups(M2M → contacts.ContactGroup) — used when visibility = GROUP- Image fields:
header_image,logo,favicon,animated_logo - Colour fields:
logo_bg_color,header_bg_color,footer_bg_color - Logo carousel:
logo_opacity,logo_text_opacity,logo_slide_interval,logo_text_align,logo_text_valign - Nav:
navigation_layout(LEFT/TOP/RIGHT),navigation_theme(LIGHT/DARK/TRANSPARENT) - Contact form:
contact_show_phone,contact_phone_required,contact_email_required
URL patterns (microsites/urls.py)
| Name | Path | View |
|---|---|---|
| microsite_owner_dashboard | /microsites/ | owner_dashboard |
| microsite_create | /microsites/new/ | website_create |
| microsite_edit | /microsites/<pk>/edit/ | website_edit |
| microsite_builder | /microsites/<pk>/builder/ | website_builder |
| microsite_owner_preview | /microsites/<pk>/preview/ | website_owner_preview |
| microsite_page_add | /microsites/<pk>/pages/add/ | microsite_page_add |
| microsite_page_edit | /microsites/<pk>/pages/<page_id>/edit/ | microsite_page_edit |
| microsite_page_duplicate | /microsites/<pk>/pages/<page_id>/duplicate/ | microsite_page_duplicate |
| microsite_page_details_update | /microsites/<pk>/pages/<page_id>/details/ | microsite_page_details_update |
| microsite_page_delete | /microsites/<pk>/pages/<page_id>/delete/ | microsite_page_delete |
| microsite_page_visibility_update | /microsites/<pk>/pages/<page_id>/visibility/ | microsite_page_visibility_update |
| microsite_page_reorder | /microsites/<pk>/pages/reorder/ | microsite_page_reorder |
| microsite_builder_settings_update | /microsites/<pk>/builder/settings/ | microsite_builder_settings_update |
| microsite_block_add | /microsites/<pk>/pages/<page_id>/blocks/add/ | microsite_block_add |
| microsite_block_bulk_reorder | /microsites/<pk>/pages/<page_id>/blocks/reorder/ | microsite_block_bulk_reorder |
| microsite_block_edit | /microsites/<pk>/pages/<page_id>/blocks/<block_id>/edit/ | microsite_block_edit |
| microsite_block_delete | /microsites/<pk>/pages/<page_id>/blocks/<block_id>/delete/ | microsite_block_delete |
| microsite_block_reorder | /microsites/<pk>/pages/<page_id>/blocks/<block_id>/reorder/ | microsite_block_reorder |
| microsite_site_duplicate | /microsites/<pk>/duplicate/ | microsite_site_duplicate |
| microsite_revisions_list | /microsites/<pk>/revisions/ | microsite_revisions_list |
| microsite_revision_restore | /microsites/<pk>/revisions/<revision_id>/restore/ | microsite_revision_restore |
| microsite_contact_submit | /microsites/<pk>/contact/ | contact_submit |
| microsite_contact_messages | /microsites/<pk>/messages/ | contact_messages_list |
| microsite_message_status_update | /microsites/<pk>/messages/<msg_id>/status/ | contact_message_status_update |
| microsite_publish_public | /microsites/<pk>/publish/ | website_publish_public |
| microsite_publish_owner | /microsites/<pk>/unpublish/ | website_publish_owner |
| microsite_delete | /microsites/<pk>/delete/ | website_delete |
| microsite_export | /microsites/<pk>/export/ | microsite_export |
| microsite_import | /microsites/import/ | microsite_import |
| microsite_template_download | /microsites/download-template/ | microsite_template_download |
| microsite_public_slug_check | /microsites/api/public-slug-check/ | public_slug_check |
| microsite_public_index | /sites/ | public_sites_index |
| microsite_public_site | /sites/<site_slug>/ | public_site_by_slug |
| microsite_public_home | /u/<username>/websites/<website_slug>/ | public_home |
| microsite_public_tab | /u/<username>/websites/<website_slug>/<section_slug>/<tab_slug>/ | public_tab |
ZIP format
mysite-slug.zip
├── site.json ← UTF-8 JSON, version "1"
├── README.md ← generated by _build_readme_text(website.title)
├── AI_CONTEXT.md ← module constant _AI_CONTEXT_TEXT
├── images/
│ ├── header_image.<ext>
│ ├── logo.<ext>
│ ├── favicon.<ext>
│ ├── animated_logo.<ext>
│ └── logos/0.<ext>, 1.<ext> ...
└── files/
└── <section_slug>_<asset_idx>.<ext>
site.json structure:
version:"1"website: all Website scalar fields (not PKs, not public_slug, not is_published)sections: array of sections → assets → blocks.parent_page_indexis an array index (not a PK) for cross-DB portability.logos: array of WebsiteLogo entries
NOT exported: public_slug, is_published, publish_visibility, allowed_groups, contact messages.
Security validation (_validate_zip_security)
Runs before any DB writes in microsite_import. Raises ValueError on violation:
- Path traversal: any entry name containing
..or starting with/or\ - Unexpected paths: files outside site.json, README.md, AI_CONTEXT.md, images/, files/
- Disallowed image exts: only
.jpg .jpeg .png .gif .ico .webp .svg - Disallowed file exts: only
.pdf .md .html .htm .txt - ZIP bomb: >500 entries, total uncompressed >300 MB, single file >50 MB
- Unsafe HTML in rawhtml/content_text: regex scan for
<script,javascript:,on\w+=,<iframe,<object,<embed
Key helper functions (microsites/views.py)
| Function | Purpose |
|---|---|
_build_export_data(website, zf) | Writes all binary files into open ZipFile; returns site.json dict. Also writes README.md and AI_CONTEXT.md. |
_build_readme_text(site_title) | Returns the README.md string (personalised with site title). Also used by microsite_template_download. |
_AI_CONTEXT_TEXT | Module-level constant string — the AI_CONTEXT.md content. Identical in every ZIP. |
_validate_zip_security(zf, data) | Raises ValueError with user-friendly message on any security violation. |
_zip_read_field(field, arc_name, zf, zip_names) | Saves a binary file from an open ZipFile into a Django FileField (save=False). |
_apply_zip_sections(data, zf, zip_names, website) | Two-pass creation of sections, assets, blocks, and logos from parsed ZIP data on an existing Website object. |
_build_site_snapshot(website) | Returns JSON-serialisable snapshot keyed by PKs. Used for WebsiteRevision (different schema from export — uses PKs not indices). |
_unique_site_slug(user, base_slug) | Returns a slug unique to this user. Tries slug → slug-2 → slug-3 ... up to 200 attempts, then falls back to UUID suffix. |
_get_owner_website_or_404(user, pk) | Ownership guard. Raises 404 if the website does not belong to the user. |
Block type content schemas (PageBlock.content JSONField)
| block_type | content schema |
|---|---|
| text | {"html": "<p>...</p>"} |
| rawhtml | {"html": "<div class='container'>...</div>"} — Bootstrap 5 only, no scripts |
| image | {"url": "https://...", "alt": "", "caption": "", "width": "100%"} |
| button | {"text": "Click", "url": "#slug or https://...", "style": "primary"} |
| collapse | {"title": "Question?", "body": "<p>Answer</p>"} |
| form | {"show_phone": false, "phone_required": false, "email_required": true} |
| map | {"query": "123 Main St, Toronto", "height": 400} |
| youtube | {"url": "https://youtube.com/watch?v=..."} |
| video | {"url": "https://..."} |
| file | {"label": "Download PDF", "url": "https://..."} |
| embed | {"html": "<iframe ...></iframe>"} |
| columns | {"col1": "<p>...</p>", "col2": "<p>...</p>"} |
| toc | {} — auto-generated from page headings |
| navbar | {"links": [{"label": "Home", "url": "#"}]} |
| divider | {} |
| spacer | {"height": 40} |
Template architecture
| Template | Purpose |
|---|---|
microsites/owner_list.html | Dashboard — lists all sites owned by the user. Contains Import ZIP, Starter Template, + New Site buttons. |
microsites/website_preview.html | Full builder SPA. Contains all inline JS (MS_PROMPTS, MS_DESCS, MS_TEMPLATES, msAiPanel(), msBlockBody(), block save/undo/delete handlers). Also includes the topbar and settings tab. |
microsites/_block_row.html | Django template included for each block in edit mode. Renders the correct form fields per block_type. Contains the rawhtml AI panel and tips panel. |
microsites/import.html | ZIP import form. Shows the optional existing-site dropdown when user has sites. |
microsites/revisions_list.html | Revision history. Each card has an expandable preview showing block counts and snippets. |
microsites/_public_canvas.html | Public page renderer. Renders each block_type via template conditionals. Contact form is rendered for pages named "contact". |
Running tests
python manage.py test microsites
Adding a new block type — checklist
- Add to
_VALID_BLOCK_TYPESset inmicrosites/views.py - Add a
case "newtype":branch inmsBlockBody()inwebsite_preview.htmlto return the edit form HTML - Add a save/load case in the block save JS handler (
msBlockSave()or equivalent) inwebsite_preview.html - Add a render branch in
_public_canvas.htmlso it displays on the public site - Add the block's schema to
_block_row.htmlfor the edit-existing-block path - Add the block to the Quick Start tiles array in
website_preview.htmlif it warrants an AI prompt template - Add the block type and its
contentschema to_AI_CONTEXT_TEXTinmicrosites/views.pyso exported ZIPs include it - Update this developer guide