SØAD Base Usage
SØAD Base ("soad base") is a ready-made foundation app that ships the features every web system needs — user management, authentication, role-based access control (RBAC), settings, notifications/inbox, a multi-app portal, email templates, and a document-management (DMS) example. You build your application on top of it and reuse those features instead of rebuilding them. This page explains how an application integrates with and uses soad base.
Overview & integration model
soad base is deployed as its own SØAD application under the group namespace base (routes look like /base/t/{group}/{code}/{action}). Your extension is its own SØAD application (its own namespace, e.g. default) deployed alongside it, and it imports base's modules across the app boundary:
from base.core import users, access # user + RBAC services
from base.helper import system_helper # shared utilities
from base.config import info # cached settings
from base.web import login # the login flow
from models import Base_user, Base_role # shared base tables
Key consequences:
- You share one database with base. Base owns the
base_*tables (users, roles, RBAC, settings, inbox); your app owns its own business tables. - Authentication, users, roles, settings, and files are provided by base — you call them, you don't reimplement them.
- You own your business logic, navigation, and screens. You define your own groups, transactions, views, and (typically) your own page layout.
The golden rule: go through core (and the helper/services facades) rather than writing base_* tables directly. core is the internal interface to base — it keeps password hashing, RBAC, and session behaviour consistent across every app that shares the base.
Getting started — build your first screen
Add a feature to your app that reuses soad base. We use a small "Reports" admin screen as the example.
1. Create your table (IDE SQL editor)
Every table needs a primary key named id. Prefix tables by module. After creating it, run Introspect so the model class Rpt_report is generated.
CREATE TABLE rpt_report (
id VARCHAR(32) NOT NULL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
body TEXT,
status_id INT DEFAULT 1,
created_on DATETIME, created_by VARCHAR(32),
updated_on DATETIME, updated_by VARCHAR(32)
);
2. Write the transaction
Routes are convention-based: a file reports/report.py with class Report and method view is reachable at .../t/reports/report.
from utils import render
from base.helper import system_helper
from models import Rpt_report
from java.time import LocalDateTime
import uuid
class Report(object):
def view(self, ctx): # GET (default action)
ctx.output["page_title"] = "Reports"
ctx.go_to = render.as_view(ctx, "report", loc="")
def save(self, ctx): # POST (docstring marks POST)
"""POST"""
r = ctx.getRequest()
uid = system_helper.get_user_id(ctx) # base helper
rid = r.getParameter("id")
report = Rpt_report.findById(rid) if rid else Rpt_report()
if not rid:
report.set("id", uuid.uuid4().hex)
report.set("created_on", LocalDateTime.now())
report.set("created_by", uid)
report.set("title", r.getParameter("title"))
report.set("updated_on", LocalDateTime.now())
report.set("updated_by", uid)
report.saveIt() if rid else report.insert()
ctx.go_to = "/t/reports/report" # PRG redirect
Jython, not CPython: no f-strings (use "%s" % x); import Java directly; a .py with non-ASCII needs # -*- coding: utf-8 -*- on line 1.
3. Add the views
Views live in a _{code}/ folder as Handlebars HTML: reports/_report/report.html. Use {{ctxPath}} for links; drive search/pagination with HTMX. Listings render as partials with ctx.page_layout = False.
4. Register access
Base gates every request; a new endpoint is unreachable until it's registered and (for role-gated ones) granted to a role. Use the base core.access service — don't write the tables by hand:
from base.core import access
from base.core.role import Roles
A = access.Access
ep = A.register_endpoint("/t/reports/report", A.AUTHORIZED, created_by=uid)
role = A.create_role("RPTVIEW", "Report Viewer", created_by=uid) or Roles.get_by_code("RPTVIEW")
A.grant_access(role.get("id"), ep.get("id"), created_by=uid)
A.assign_role_to_user(target_user_id, role_code="RPTVIEW", created_by=uid)
A.refresh_session(ctx, target_user_id) # if that user is logged in
Access levels: whitelist (public), authenticated (any logged-in user), authorized (role-gated). Matching is by containment, so registering /t/reports/report also covers /t/reports/report/save.
Core interface reference
core is the internal interface to soad base. Import the submodule you need. Return conventions: creators return the created model/wrapper or None on validation failure (so the request transaction is not rolled back); destructive RBAC ops return (ok, reason); checks return bool/list; reads return model objects or None.
core.auth.Auth — login session
Constructed with ctx. Auth(ctx).generate_user_session(user_id, last_login, appid=None) builds the session (sets SESSION_USER_ID, SESSION_ALLOWED_URL, SESSION_ALLOWED_MENU, SESSION_APPID, LAST_LOGIN, OAUTH_USER_OBJECT) after identity is verified. Credential verification, lockout, and 2FA live in base.web.login.
core.users — account creation
| Method | Returns |
|---|---|
Users().create_account(roles_code, username=None, password=None, full_name=None, email=None, login_provider=None, created_by=None, expiry=None, is_active=False) | UserObject or None on validation failure. expiry is in minutes (default +1 day). OAuth users (email+login_provider) are auto-activated. |
UserObject(base_user=obj | id=... | login_id=...) | wrapper: .get/.set/.saveIt/.delete, .get_user_details(), .assign_role(codes=[]) |
core.user_lifecycle.UserLifecycle — beyond create
| Method | Returns |
|---|---|
update_profile(user_id, full_name=None, mobile_no=None, updated_by=None) | details row / None |
change_password(user_id, new_password, updated_by=None) | (ok, reason) — enforces policy + records history |
generate_password_reset(user_id, expiry_minutes=30) / reset_password(user_id, token, new_password, updated_by=None) | token / (ok, reason) |
activate(user_id, code=None) | (ok, reason) — validates code + expiry; idempotent |
enable / disable / soft_delete / set_status(user_id, status_id, updated_by=None) | user row / None |
core.role.Roles — reads & checks (read-only)
Roles.get_all(), get_by_code(code), get_by_id(id), exists(code), has_role(user_id, role_code) → bool, get_user_role_codes(user_id) → list[str]. Role writes are in core.access.
core.companies / core.dms
CompanyObject(id=... | get_first=True) wraps the company record (get/set/saveIt/delete). dms.create_folder(...), dms.Folder(...), dms.Document(...) handle folders/documents; richer document operations live in helper/dms_helper.py.
Helpers & services
base.helper.system_helper — the toolbox
| Call | Purpose |
|---|---|
get_user_id(ctx) / get_appid(ctx) | current user / app id from session |
is_role_match(code, user_id) | role check (bool) |
hash_password(password, salt) / validate_password_compliance(password, existing_user_id=None) | hashing / policy check |
push_inbox(ctx, code, variables, user_ids=None) | in-app notification (targeted list or broadcast) |
store_file(bytes, fn, path) / get_file(path) | local file store / fetch |
format_datetime(dt, "dd/MM/yyyy") | date format (also "time_ago") |
println(ctx, ...) / record_ai_usage(...) | logging / LLM usage log |
page_status_successful/unsuccessful/not_found/restricted(ctx, ...) | standard result screens |
base.config.info — cached settings
get_app_details(), get_system_setting(), get_setting_extra(scope, key), get_whitelist(scope=None), get_additional_url(). These are cached in module globals — after writing settings, call the matching info.sync_*().
Email (utils.mailer + template/emails)
from base.config import info
from utils import render, mailer
ctx.output["app_details"] = info.get_app_details()
body = render.as_view(ctx, "account_activation", group="template", code="emails", loc="")()
mailer.send(info.get_app_details().get("sender_email"), to_email, subject, body, html=True)
base.services
services.oauth (Google/Microsoft/LinkedIn), services.collabora (online document editing), services.s3 (object storage). Note: services/s3.py is currently a stub — keep dms_storage_type = "local" until the AWS SDK is wired up.
RBAC & the HTTP API
An endpoint's access_level in base_url_filter is whitelist (public), authenticated (any logged-in user), or authorized (role-gated). Users get roles via base_user_role; a role is granted authorized URLs via base_url_access. Effective access = all whitelist + all authenticated (if logged in) + the authorized URLs granted to any of the user's roles. Base caches this on the session at login, so a grant/revoke does not affect a logged-in user until Access.refresh_session(ctx, user_id) (or re-login).
core.access.Access — programmatic RBAC
| Area | Methods |
|---|---|
| Endpoints | register_endpoint, update_endpoint, set_endpoint_blocked, delete_endpoint → (ok,reason), get_endpoint, list_endpoints |
| Roles | create_role, update_role, set_role_status, delete_role → (ok,reason) |
| User↔role | assign_role_to_user (idempotent), revoke_role_from_user, get_user_roles, has_role |
| Role↔url | grant_access (idempotent), revoke_access, set_role_access, get_role_endpoints |
| Checks | can_access(user_id, url, scope=None) → bool, get_allowed_urls(user_id, scope=None), refresh_session(ctx, user_id) |
The api/ group — RBAC over HTTP
For provisioning from outside a request, base exposes an HTTP API backed by core.access, authenticated with username + token (X-Username / X-Token headers, or username/token params).
# create a role
curl -X POST ".../base/t/api/role/create" \
-H "X-Username: soadadmin" -H "X-Token: <token>" \
-H "Content-Type: application/json" \
-d '[{"code":"RPTVIEW","name":"Report Viewer"}]'
# check access
curl ".../base/t/api/access/can_access?user_id=<uid>&url=/t/reports/report" \
-H "X-Username: soadadmin" -H "X-Token: <token>"
Endpoints include /base/t/api/url/register, /base/t/api/role/{create,update,delete,list}, and /base/t/api/access/{assign_role,revoke_role,user_roles,grant,revoke,set_role_access,role_endpoints,can_access,allowed_urls}. Each api endpoint must itself be a whitelist URL (the username+token check happens inside the code).
Example app patterns
Patterns observed in a real application built on soad base (an audit / certification system):
- Your app is its own SØAD app (namespace
default) with its own groups (admin/ app/ web/ audit/ customer/ client/ api/), importing base viafrom base.*and sharing base's database. - It owns its page layout — defines its own
Layoutclass (does not inheritbase.admin.layout.Layout), reusing base only for settings/helpers. - App-owned navigation — keeps a hardcoded menu list filtered by the session's
SESSION_ALLOWED_URL(populated by base at login from the user's role grants). - Provisions portal users through
core.users.create_account(roles + activation) rather than writingbase_userdirectly. - Reuses
base.web.loginfor its portals' authentication, andcore.dmsfor file/document storage. - Reads base models directly (
Base_user,Base_user_role,Base_role,Base_setting_extra) wherecorehas no reader. - Follows the same Paginator + HTMX listing, offcanvas/modal form, and
response_messagetoast rhythm as base.
Guidance for new apps: prefer core / helper / the api/ group for users, RBAC, settings, files, and email; fall back to direct model access only where base doesn't yet provide an interface — and treat those as candidates to push into core.
Gotchas & known issues
services/s3.pyis stubbed — usedms_storage_type = "local".- Avoid
UserObject.send_email_activation(),core.dms.copy_document(S3 branch), andCompanyObject(base_company=...)— known bugs; use the alternatives above. info.*settings are cached — callinfo.sync_*()after writing settings.- RBAC changes don't affect a logged-in user until
Access.refresh_session(...)or re-login.