A Flask boilerplate for building web applications that solve computational social choice problems: fair allocation, voting and participatory budgeting.
You bring the problem; the package brings the admin dashboard, the participant ballot GUI, invitations and authentication, dummy-user simulation, rule execution with a full audit log, and CSV export.
- Flask-shaped.
create_app()is an ordinary application factory; the three blueprints are ordinary blueprints. - Multilingual. English and Hebrew (right-to-left) ship with the package;
a new language is one file (see
translations.md). - Solver-agnostic.
adapters/turns the SQL rows into plain dicts and lists, and bridges them intofairpyx,abcvotingandpabutools.
- Python 3.10 or newer (
python --version). - No database server needed: the default backend is the standard library's
sqlite3. PostgreSQL, MySQL and MariaDB are a config key away. - The solver libraries are optional — the package ships three built-in rules
(
approval_scoring,borda,greedy_budget) that depend on nothing.
git clone https://github.com/ariel-research/comsocwebapp.git
cd comsocwebapp
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e . # core: Flask only
pip install -e ".[oauth]" # plus Authlib (for external login providers)
pip install -e ".[postgresql]" # plus psycopg (or [mysql] for PyMySQL)
pip install -e ".[all]" # plus fairpyx, abcvoting, pabutools, drivers
pip install -e ".[dev]" # plus pytest and stress testsIndividual extras are allocation (fairpyx), voting (abcvoting),
budgeting (pabutools), oauth, postgresql and mysql.
Those commands install the package. An application built on it needs a different set, and which set depends entirely on how it is configured: its database driver, the solver libraries whose rules it registers, a database, an account that owns it. Rather than write that down in a README of your own and let it drift, ask the application:
flask --app myapp install-script --output install.sh
flask --app myapp install-script --shell powershell -o install.ps1The result is a numbered script covering the whole sequence — packages,
database, user, tables, first administrator, and a closing db-info check —
with only the steps this configuration actually calls for:
| Configured as | What the script contains |
|---|---|
| SQLite | pip install comsocwebapp, mkdir for the directory, init-db, chmod 600. No CREATE DATABASE, no user, no driver. |
| PostgreSQL | comsocwebapp[postgresql], then CREATE ROLE and CREATE DATABASE … OWNER guarded by WHERE NOT EXISTS, run through psql as a superuser. |
| MySQL / MariaDB | comsocwebapp[mysql], then CREATE DATABASE … utf8mb4 and a GRANT scoped to that one database. |
Beyond the engine, two more things come from the configuration: the solver
libraries, taken from every register_*_rule call the application makes, and
oauth, added when a provider's client id and secret are both set.
It writes a script; it never runs one, and never connects. That is not caution, it is the only arrangement that works:
- The application's own account cannot create the application's own account.
The database step needs a superuser, and at many institutions that is a
different person — so it has to be something you can read, review and send
on, with the platform-specific ways of reaching
psqlormysqlspelled out in its comments. - The driver is not installed yet. Anything that had to connect in order to work out what to do would fail on precisely the machine an installer is for.
No password is written into the file. The steps that need one read
$DATABASE_PASSWORD from the environment as they run, and escape it for the
SQL literal it lands in. Keep it in .env, and source that before running
the script.
Two caveats worth knowing:
- The script is a snapshot of the configuration at the moment it was generated. Its header records that configuration and the command that produced it, so regenerate rather than edit when the configuration changes.
- A database or user name that is not a plain identifier — letters, digits and
underscores — is refused rather than quoted, because the name is
interpolated into
CREATE DATABASE. Rename it, or create the database by hand —database-engines.mdhas the statements.
In Python, the same text is install.build_script(app):
from comsocwebapp import create_app, install
app = create_app({"DATABASE_URL": "postgresql://comsoc_app:…@localhost/comsoc"})
print(install.build_script(app, "bash", app_ref="myapp"))export FLASK_APP=comsocwebapp # Windows PowerShell: $env:FLASK_APP="comsocwebapp"
flask init-db # 1. create the tables
flask create-admin # 2. create the first admin (prompts for email + password)
flask run --debug # 3. serve on http://127.0.0.1:5000/Then open http://127.0.0.1:5000/admin/ and log in with the account you just created.
flask init-dbdrops and recreates every table — it is the "start a fresh event" command, not a migration.
Without shell variables:
flask --app comsocwebapp init-db
flask --app comsocwebapp create-admin
flask --app comsocwebapp run --debugThe database file lands in instance/comsocwebapp.sqlite. Override the
location and the session key with environment variables:
export DATABASE_FILE=/var/lib/comsoc/event.sqlite
export COMSOCWEBAPP_SECRET_KEY="$(python -c 'import secrets;print(secrets.token_hex(32))')"
export COMSOCWEBAPP_LANGUAGE=he # GUI language; default "en"
export DATABASE_URL=postgresql://user:pw@host/comsoc # another engine entirelyEvery setting is spelled in the environment exactly as it is in app.config —
DATABASE_FILE, DATABASE_URL, OAUTH_GITHUB_CLIENT_ID and the rest. The two
exceptions above take a COMSOCWEBAPP_ prefix because their bare names are not
ours to claim: SECRET_KEY is defined by half of PyPI, and LANGUAGE is a
POSIX locale variable that is already set on most Linux machines.
Setting COMSOCWEBAPP_SECRET_KEY is required in production — the default
"dev" key exists only so flask run works out of the box.
Each example seeds its own database and starts a server, so you can see a complete application in one command:
python examples/participatory_budgeting.py # city budget, approval ballots
python examples/committee_voting.py # board election, approval ballots, in Hebrew
python examples/fair_allocation.py # estate division, point ballotsThey all print an admin login (admin@example.com / admin) and an invitation
link you can open in a private window to see the participant side.
Each example splits its configuration in two, which is the split worth copying into an application of your own:
- Not secret — the port, the database path, the language, the template
folder — is the
CONFIGdict at the top of the file. - Secret — the session key, the external-login client secrets, the database
password — is an
examples/.envfile, never the code.
Each example calls load_dotenv() on start-up (via
python-dotenv, a core dependency), so
the file is picked up automatically and create_app reads those values from the
environment. The .env file is git-ignored, so your secrets never get
committed; a tracked template, examples/example.env, shows what it may contain,
with every line commented out.
Copy the template and fill in what you need:
cp examples/example.env examples/.env # Windows: copy examples\example.env examples\.envThen edit examples/.env — for instance, to switch on GitHub sign-in, uncomment
and fill:
OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxxxxx
OAUTH_GITHUB_CLIENT_SECRET=yyyyyyyyA commented line (or a missing .env altogether) simply changes nothing, so the
examples run out of the box with no config at all. The keys read are
COMSOCWEBAPP_SECRET_KEY, the OAUTH_<GOOGLE|GITHUB|ORCID>_CLIENT_ID /
_CLIENT_SECRET pairs — see external-login.md for how to
obtain them and register the redirect URI — and DATABASE_PASSWORD or
DATABASE_URL (see database-engines.md). Only the
session key is prefixed, for the reason given above.
Left unset, COMSOCWEBAPP_SECRET_KEY falls back to the development key "dev",
which is fine for a demo on your own machine and must not be used anywhere
reachable: anyone who knows it can forge a session, including an admin's.
- New poll — give it a title, a problem type (
committee,budgetingorallocation), a preference format (approval,ranking,points,budget) and the limit that goes with the type: the committee size, the money to spend, or the points a ballot distributes. The type is what tells a committee election from a participatory budget — the same approval ballots serve both — so it decides which rules are offered and whether project costs are collected. - Add options one by one, or bulk-upload a CSV with the columns
name,description,cost. - Generate invitations — personal links die once redeemed; generic
links can be mailed to a list, and the unique index on
users.emailis what stops anyone voting twice. - Generate dummy users to test a rule before real people arrive; choose
the distribution (
uniform,normal,exponential), the bounds and a seed for reproducibility. Delete them all with one button. - Set the status to
openso participants can vote (closedlocks it). - Run a rule over real users only, dummy users only, or both. The outcome
and the step-by-step log are stored in
execution_logsand shown to admins and participants alike. - Export anonymised preferences and execution logs as CSV.
from comsocwebapp import create_app, db, dummy, adapters, rules
app = create_app()
with app.app_context():
db.init_db()
poll_id = db.insert_returning_id(
"INSERT INTO polls (title, pref_format, status, budget_limit)"
" VALUES (?, 'approval', 'open', 1000)",
("My election",))
db.execute("INSERT INTO options (poll_id, name, description, cost)"
" VALUES (?, ?, ?, ?)", (poll_id, "Park", "A new park", 400))
dummy.generate_dummy_users(poll_id, 50, seed=1)
result = rules.run_rule("approval_scoring", poll_id,
adapters.SCOPE_DUMMY, committee_size=2)
rules.record_execution(poll_id, "approval_scoring", result)
print(result.outcome, result.log_lines, sep="\n")from comsocwebapp import adapters, rules
@rules.register_rule("my_rule", formats=("approval",),
poll_types=("committee",))
def my_rule(poll_id, scope=adapters.SCOPE_ALL, committee_size=None, **params):
# This run's size if the admin typed one, else the size the poll
# declares -- never a constant in your own file.
size = rules.committee_size_for(poll_id, committee_size)
matrix = adapters.preference_matrix(poll_id, scope) # {user: {option: value}}
winners = ...
return rules.RuleResult(outcome=winners,
log_lines=["why these winners were chosen"])The rule appears in the admin's dropdown as soon as the module is imported.
Two optional filters narrow where it is offered, and they answer different
questions: formats=("approval",) — what ballots it reads — and
poll_types=("committee",) — what problem it solves, matched against the
poll's own poll_type column. Omit either to mean "any". See
rules.register_rule.
instance = adapters.to_fairpyx_instance(poll_id) # fairpyx
profile, ids = adapters.to_abcvoting_profile(poll_id) # abcvoting
instance, profile = adapters.to_pabutools_instance(poll_id) # pabutoolsEach imports its library lazily, so an install without that extra still runs.
The package registers no library rules of its own — installing abcvoting
adds nothing to the admin's dropdown by itself. The three libraries offer dozens
of rules between them, and which ones your voters should be offered is your
application's decision, not the package's. Asking for one is one line:
from comsocwebapp import adapters
for rule_id in ("seqphragmen", "cc", "monroe"): # approval-based committees
adapters.register_abcvoting_rule(rule_id)
adapters.register_pabutools_rule("greedy_utilitarian_welfare", # budgeting
sat_class="Cost_Sat")
adapters.register_fairpyx_rule("iterated_maximum_matching") # allocationEach rule then appears in the admin's dropdown, named <library>_<rule>, on
any poll whose preference format fits it. Put these calls at module level in
your application file, next to your own @rules.register_rule functions: the
registry is filled at import time, before the app serves anything.
| Call | Takes | Choose from |
|---|---|---|
adapters.register_abcvoting_rule(rule_id) |
an abcvoting rule id | abcvoting.abcrules.MAIN_RULE_IDS — ~27: pav, seqphragmen, cc, monroe, equal-shares, minimaxav, … |
adapters.register_pabutools_rule(name) |
a function name, plus an optional sat_class |
pabutools.rules — method_of_equal_shares, sequential_phragmen, greedy_utilitarian_welfare, maximin_support, … |
adapters.register_fairpyx_rule(algorithm) |
an algorithm name | fairpyx.algorithms — round_robin, iterated_maximum_matching, almost_egalitarian_allocation, … |
All three also take name= to override the generated rule name and headline=
(abcvoting takes its headline from the library) to set the first line of the
run log. Nothing that belongs to a run is fixed at registration — the
committee size and the scope are chosen by the admin each time the rule is
executed, so one registration serves every committee size. They handle the
parts that are easy to get wrong:
- The library is imported when the rule runs, never at registration, so your file still loads where the library is missing.
- A missing library registers nothing and returns
None, so a rule that could not run is never offered to an admin — noavailable()check needed in your code. - Each call binds its own rule id, so a
forloop is safe. Writing the decorator inline in a loop instead is the classic bug: every rule would end up computing the last id. - The library's own account of the run goes into the log — see below.
All four runnable examples do this, and between them they are the reference:
committee_voting.py and standalone_faculty_hiring/app.py register abcvoting
rules, participatory_budgeting.py three pabutools rules,
fair_allocation.py four fairpyx algorithms. Copy the block from whichever one
matches your problem.
If a rule needs shaping the helper cannot express, drop to
@rules.register_rule and use the bridge directly — that is what the helpers
themselves do. To add a whole library the package does not know about yet, see
comsocwebapp/adapters/README.md.
Every run writes an execution log that both the admin and the participants can read. It opens with the app's own summary — rule, electorate size, winners by name — and then, for a library-backed rule, continues with the library's own account of how it got there. For sequential Phragmén that is one block per seat: the candidate added, the maximum load it produced, and any tie that had to be broken.
None of the three libraries produces that by default, and no two produce it the
same way, so the adapters ask each in its own dialect: abcvoting is put into
DETAILS verbosity and its printing captured, pabutools rules are called with
verbose=True where they accept it, fairpyx narrates through the standard
logging module. Coverage follows what each library offers — abcvoting's
sequential rules are the most talkative, pabutools.sequential_phragmen takes
no tracing argument at all and contributes nothing beyond the summary.
A few algorithms explain themselves to each voter separately instead:
fairpyx.iterated_maximum_matching tells an agent, in the second person, what
it valued and what it therefore received. That text quotes the reader's own
ballot, so it is stored per user rather than in the shared log — a participant
sees only their own under What this means for you, and the admin, who can
already read every ballot, sees them all. See
comsocwebapp/adapters/README.md for how a
rule of your own supplies either kind.
Seven tables — users, polls, options, invitations, preferences,
execution_logs, execution_log_voters — defined in
comsocwebapp/schema.sql exactly as specified in
database.md. Only VARCHAR, INTEGER, TIMESTAMP and
TEXT are used — TEXT for the two columns holding a solver library's own
narrative, which has no sensible length bound. Booleans are INTEGER 0/1;
there are no JSON columns, arrays or ENUMs.
SQLite, PostgreSQL, MySQL or MariaDB — a matter of configuration, not of code:
pip install "comsocwebapp[postgresql]" # or [mysql]
export DATABASE_URL="postgresql://user:pw@localhost:5432/comsoc"
flask --app comsocwebapp init-dbThe default is SQLite, which needs no server and no driver. flask db-info
shows what you are connected to, and flask db-copy --to URL moves an existing
event to another engine — keeping every id, so invitation links still work
afterwards. Only one construct in the whole project is dialect-dependent (the
auto-increment column in schema.sql, rewritten when it runs); the runtime
queries avoid RETURNING, ON CONFLICT, MERGE and everything else specific
to an engine, and where an UPSERT would normally be used
db.upsert_preference() does a portable UPDATE-then-INSERT.
database-engines.md has the configuration keys, the
per-engine setup, the migration procedure and a troubleshooting table.
- Passwords are stored as Werkzeug PBKDF2 hashes; login errors never reveal whether an email is registered.
- Every SQL parameter is bound, never interpolated.
- Session cookies are signed,
HttpOnlyandSameSite=Lax. - A dependency-free CSRF token guards every POST (disable with
CSRF_ENABLED = Falseif your app already uses Flask-WTF). - Personal invitation tokens are 32 URL-safe characters from
secrets, and are consumed on first use.
The whole GUI is translated, and an application picks its language with one config key:
app = create_app({"LANGUAGE": "he"}) # Hebrew, right-to-left"en" (the default) and "he" ship with the package. "he" is right-to-left:
the page comes out as <html dir="rtl"> and the stylesheet — written with
logical properties throughout — mirrors itself, with no separate rtl stylesheet.
python examples/committee_voting.py is the working demonstration.
Adding a language is one file: copy
comsocwebapp/languages/en.py to fr.py, set
NAME and DIRECTION, and translate the right-hand side of LABELS. Files in
that folder are discovered automatically; an application that cannot write into
the installed package calls i18n.register_language() instead.
translations.md is the full guide, including what is
deliberately not translated (your poll titles, option names and rule names)
and how pytest tests/test_v5.py checks a new translation for missing keys and
mangled placeholders.
Participants can register and log in with an existing Google, GitHub or ORCID
account. The feature is optional and off by default: install the oauth extra
and set each provider's OAUTH_<PROVIDER>_CLIENT_ID / _CLIENT_SECRET, and the
buttons appear on their own. See external-login.md for the
full setup, the identity model, and how to add another provider.
pip install -e ".[dev]"
pytestcomsocwebapp/
├── __init__.py application factory, `create-admin` CLI command
├── db.py connections, parameterised queries, portable UPSERT
├── dialects.py SQLite / PostgreSQL / MySQL: the few places they differ
├── install.py `install-script`: the install this configuration needs
├── schema.sql the seven tables
├── poll.py wrapper to create a poll and number its options
├── auth.py invitation tokens, registration, login, guards
├── oauth.py optional Google / GitHub / ORCID sign-in
├── admin.py admin blueprint (/admin/...)
├── participant.py participant blueprint (/, /vote/..., /results/...)
├── adapters/ SQL rows -> dicts -> fairpyx / abcvoting / pabutools
│ ├── generic.py library-independent shapes
│ └── <library>.py one file per solver library (+ README.md)
├── dummy.py bulk dummy users with randomised preferences
├── rules.py rule registry, built-in rules, execution logging
├── security.py CSRF protection
├── i18n.py GUI language registry, lookup and fallbacks
├── languages/ one file per language (en.py, he.py)
├── templates/ Jinja templates for both GUIs
└── static/style.css responsive, dependency-free, direction-agnostic styling
examples/ runnable reference applications (+ a standalone one)
tests/ pytest suite
See LICENSE.