Skip to content

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

MethodReturns
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

MethodReturns
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

CallPurpose
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

AreaMethods
Endpointsregister_endpoint, update_endpoint, set_endpoint_blocked, delete_endpoint(ok,reason), get_endpoint, list_endpoints
Rolescreate_role, update_role, set_role_status, delete_role(ok,reason)
User↔roleassign_role_to_user (idempotent), revoke_role_from_user, get_user_roles, has_role
Role↔urlgrant_access (idempotent), revoke_access, set_role_access, get_role_endpoints
Checkscan_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 via from base.* and sharing base's database.
  • It owns its page layout — defines its own Layout class (does not inherit base.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 writing base_user directly.
  • Reuses base.web.login for its portals' authentication, and core.dms for file/document storage.
  • Reads base models directly (Base_user, Base_user_role, Base_role, Base_setting_extra) where core has no reader.
  • Follows the same Paginator + HTMX listing, offcanvas/modal form, and response_message toast 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.py is stubbed — use dms_storage_type = "local".
  • Avoid UserObject.send_email_activation(), core.dms.copy_document (S3 branch), and CompanyObject(base_company=...) — known bugs; use the alternatives above.
  • info.* settings are cached — call info.sync_*() after writing settings.
  • RBAC changes don't affect a logged-in user until Access.refresh_session(...) or re-login.