You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/04/08 10:58:40 UTC
(tooling-trusted-releases) branch arm updated (cd7f85ef -> f99fcab3)
This is an automated email from the ASF dual-hosted git repository.
arm pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard cd7f85ef #631 - Add user preferences and colour settings for colour blindness mode
new f99fcab3 #631 - Add user preferences and colour settings for colour blindness mode
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (cd7f85ef)
\
N -- N -- N refs/heads/arm (f99fcab3)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
migrations/versions/0064_2026.04.07_2e6c48b8.py | 30 +++++++++++++++++++++++++
1 file changed, 30 insertions(+)
create mode 100644 migrations/versions/0064_2026.04.07_2e6c48b8.py
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: #631 - Add user preferences and colour settings for colour blindness mode
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
arm pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit f99fcab3e8a9f3d5c1b2ed5c7baf1a1152a448f8
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Wed Apr 8 11:57:05 2026 +0100
#631 - Add user preferences and colour settings for colour blindness mode
---
atr/db/__init__.py | 8 +
atr/get/user.py | 27 +-
atr/models/sql.py | 39 +++
atr/post/user.py | 19 ++
atr/server.py | 7 +
atr/shared/user.py | 7 +
atr/static/css/atr.css | 317 ++++++++++++++++++++++++
atr/storage/__init__.py | 2 +
atr/storage/readers/__init__.py | 3 +-
atr/storage/readers/user.py | 50 ++++
atr/storage/writers/__init__.py | 2 +
atr/storage/writers/user.py | 119 +++++++++
atr/templates/includes/topnav.html | 4 +
atr/templates/layouts/base.html | 2 +-
migrations/versions/0064_2026.04.07_2e6c48b8.py | 30 +++
15 files changed, 633 insertions(+), 3 deletions(-)
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 6b96fb1a..08911e97 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -911,6 +911,14 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
return Query(self, query)
+ def user(self, asf_uid: Opt[str] = NOT_SET) -> Query[sql.User]:
+ query = sqlmodel.select(sql.User)
+
+ if is_defined(asf_uid):
+ query = query.where(sql.User.asfuid == asf_uid)
+
+ return Query(self, query)
+
def workflow_ssh_key(
self,
fingerprint: Opt[str] = NOT_SET,
diff --git a/atr/get/user.py b/atr/get/user.py
index 61653451..2382e811 100644
--- a/atr/get/user.py
+++ b/atr/get/user.py
@@ -21,6 +21,7 @@ import atr.blueprints.get as get
import atr.form as form
import atr.htm as htm
import atr.shared as shared
+import atr.storage as storage
import atr.template as template
import atr.util as util
import atr.web as web
@@ -89,7 +90,31 @@ async def cache_get(session: web.Committer, _user_cache: Literal["user/cache"])
@get.typed
-async def tally(session: web.Committer, _user_tally: Literal["user/tally"]) -> str:
+async def preferences(session: web.Committer, _user_preferences: Literal["user/preferences"]) -> str:
+ """
+ URL: /user/preferences
+ """
+
+ existing_prefs = None
+ async with storage.read_as_foundation_committer(session.asf_uid) as rafc:
+ user = await rafc.user.user_preferences()
+ if user:
+ existing_prefs = user.preferences
+
+ block = htm.Block()
+ block.h1["User preferences"]
+ block.p["Select your preferences below."]
+ prefs_form = form.render(
+ model_cls=shared.user.UserPreferencesForm,
+ submit_label="Save",
+ defaults={"colour_blindness_mode": existing_prefs.colour_blindness_mode.value if existing_prefs else None},
+ )
+ block.append(prefs_form)
+ return await template.blank("User preferences", content=block.collect())
+
+
+@get.typed
+async def tally(_session: web.Committer, _user_tally: Literal["user/tally"]) -> str:
"""
URL: /user/tally
"""
diff --git a/atr/models/sql.py b/atr/models/sql.py
index e9479431..2f9c32f0 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -254,6 +254,17 @@ class QuarantineFileEntryV1(schema.Strict):
errors: list[str] = schema.factory(list)
+class ColourBlindnessMode(enum.StrEnum):
+ NONE = "None"
+ DEUTERANOPIA = "Deuteranopia"
+ PROTANOPIA = "Protanopia"
+ TRITANOPIA = "Tritanopia"
+
+
+class UserPreferencesEntry(schema.Subset):
+ colour_blindness_mode: ColourBlindnessMode = ColourBlindnessMode.NONE
+
+
class VoteEntry(schema.Strict):
result: bool = schema.Field(alias="result", **pydantic_example(True))
summary: str = schema.Field(alias="summary", **pydantic_example("This is a summary"))
@@ -407,6 +418,26 @@ class QuarantineFileMetadataJSON(sqlalchemy.types.TypeDecorator):
return _QUARANTINE_FILE_METADATA_ADAPTER.validate_python(value)
+_USER_PREFERENCES_ADAPTER: Final = pydantic.TypeAdapter(UserPreferencesEntry)
+
+
+class UserPreferencesJSON(sqlalchemy.types.TypeDecorator):
+ impl = sqlalchemy.JSON
+ cache_ok = True
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return None
+ if isinstance(value, dict):
+ return value
+ return _USER_PREFERENCES_ADAPTER.dump_python(value, mode="json")
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return None
+ return _USER_PREFERENCES_ADAPTER.validate_python(value)
+
+
# SQL models
@@ -1449,6 +1480,14 @@ class Revision(sqlmodel.SQLModel, table=True):
)
+# User
+class User(sqlmodel.SQLModel, table=True):
+ asfuid: str = sqlmodel.Field(primary_key=True, default=None, **example("user"))
+ preferences: UserPreferencesEntry = sqlmodel.Field(
+ default_factory=UserPreferencesEntry, sa_column=sqlalchemy.Column(UserPreferencesJSON, nullable=False)
+ )
+
+
# WorkflowStatus:
class WorkflowStatus(sqlmodel.SQLModel, table=True):
workflow_id: str = sqlmodel.Field(primary_key=True, index=True)
diff --git a/atr/post/user.py b/atr/post/user.py
index b4d5c7ad..90160c99 100644
--- a/atr/post/user.py
+++ b/atr/post/user.py
@@ -24,12 +24,31 @@ import atr.get as get
import atr.htm as htm
import atr.models as models
import atr.shared as shared
+import atr.storage as storage
import atr.tabulate as tabulate
import atr.template as template
import atr.util as util
import atr.web as web
+@post.typed
+async def save_preferences(
+ session: web.Committer,
+ _user_preferences: Literal["user/preferences"],
+ preferences_form: shared.user.UserPreferencesForm,
+) -> web.WerkzeugResponse:
+ """
+ URL: /user/preferences
+ """
+ async with storage.write(session.asf_uid) as write:
+ prefs = models.sql.UserPreferencesEntry(
+ colour_blindness_mode=models.sql.ColourBlindnessMode(preferences_form.colour_blindness_mode.value)
+ )
+ await write.as_foundation_committer().user.set_user_preferences(prefs)
+ await quart.flash("Preferences saved", "success")
+ return await session.redirect(get.user.preferences)
+
+
@post.typed
async def session_post(
session: web.Committer, _user_cache: Literal["user/cache"], user_cache_form: shared.user.UserCacheForm
diff --git a/atr/server.py b/atr/server.py
index 8a65aff6..67d6ec2b 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -63,6 +63,7 @@ import atr.paths as paths
import atr.preload as preload
import atr.pubsub as pubsub
import atr.ssh as ssh
+import atr.storage as storage
import atr.tasks as tasks
import atr.tasks.quarantine as quarantine
import atr.template as template
@@ -258,12 +259,18 @@ def _app_setup_context(app: base.QuartApp) -> None:
current_user = await asfquart.session.read()
topnav_unfinished_releases: list[tuple[str, str, list[sql.Release]]] = []
topnav_user_projects: list[tuple[str, str]] = []
+ colour_blindness_mode = sql.ColourBlindnessMode.NONE
current_uid = current_user.uid if current_user else None
if isinstance(current_uid, str):
topnav_unfinished_releases = await interaction.unfinished_releases(current_uid)
topnav_user_projects = await interaction.user_projects(current_uid)
+ async with storage.read_as_foundation_committer(current_uid) as rafc:
+ db_user = await rafc.user.user_preferences()
+ if db_user:
+ colour_blindness_mode = db_user.preferences.colour_blindness_mode
return {
+ "colour_blindness_mode": colour_blindness_mode,
"admin": admin,
"as_url": util.as_url,
"commit": metadata.commit,
diff --git a/atr/shared/user.py b/atr/shared/user.py
index d3e07064..8fdc51d0 100644
--- a/atr/shared/user.py
+++ b/atr/shared/user.py
@@ -18,6 +18,7 @@
from typing import Annotated, Literal
import atr.form as form
+import atr.models.sql as sql
type CACHE = Literal["cache"]
type DELETE = Literal["delete"]
@@ -39,3 +40,9 @@ type UserCacheForm = Annotated[
CacheUserForm | DeleteCacheForm,
form.DISCRIMINATOR,
]
+
+
+class UserPreferencesForm(form.Form):
+ colour_blindness_mode: form.Enum[sql.ColourBlindnessMode] = form.label(
+ "Colour blindness mode", widget=form.Widget.SELECT
+ )
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index f0ac4417..40c22ef8 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -532,6 +532,323 @@ a.atr-link-plain:focus-visible {
color: #cc0033 !important;
}
+/* Colour blindness mode overrides */
+
+/* Deuteranopia (red-green, most common): blue/orange palette */
+.atr-cbm-deuteranopia {
+ --bs-success: #0077bb;
+ --bs-success-rgb: 0, 119, 187;
+ --bs-danger: #ee7733;
+ --bs-danger-rgb: 238, 119, 51;
+}
+
+.atr-cbm-deuteranopia .atr-green {
+ color: #0077bb !important;
+}
+
+.atr-cbm-deuteranopia .atr-red {
+ color: #ee7733 !important;
+}
+
+.atr-cbm-deuteranopia .text-success {
+ color: #0077bb !important;
+}
+
+.atr-cbm-deuteranopia .text-danger {
+ color: #ee7733 !important;
+}
+
+.atr-cbm-deuteranopia .btn-success {
+ background-color: #0077bb;
+ border-color: #0077bb;
+}
+
+.atr-cbm-deuteranopia .btn-danger {
+ background-color: #ee7733;
+ border-color: #ee7733;
+}
+
+.atr-cbm-deuteranopia .btn-outline-success {
+ color: #0077bb;
+ border-color: #0077bb;
+}
+
+.atr-cbm-deuteranopia .btn-outline-danger {
+ color: #ee7733;
+ border-color: #ee7733;
+}
+
+.atr-cbm-deuteranopia .bg-success {
+ background-color: #0077bb !important;
+}
+
+.atr-cbm-deuteranopia .bg-danger {
+ background-color: #ee7733 !important;
+}
+
+.atr-cbm-deuteranopia .flash-success {
+ background-color: #d0e8f7;
+ border-color: #b3d4ec;
+ color: #004a73;
+}
+
+.atr-cbm-deuteranopia .flash-error {
+ background-color: #fce5d3;
+ border-color: #f5cdb0;
+ color: #8a4400;
+}
+
+.atr-cbm-deuteranopia .alert-success {
+ background-color: #d0e8f7;
+ border-color: #b3d4ec;
+ color: #004a73;
+}
+
+.atr-cbm-deuteranopia .alert-danger {
+ background-color: #fce5d3;
+ border-color: #f5cdb0;
+ color: #8a4400;
+}
+
+.atr-cbm-deuteranopia .bg-success-subtle {
+ background-color: #d0e8f7 !important;
+}
+
+.atr-cbm-deuteranopia .bg-danger-subtle {
+ background-color: #fce5d3 !important;
+}
+
+.atr-cbm-deuteranopia .text-success-emphasis {
+ color: #004a73 !important;
+}
+
+.atr-cbm-deuteranopia .text-danger-emphasis {
+ color: #8a4400 !important;
+}
+
+.atr-cbm-deuteranopia .border-success-subtle {
+ border-color: #b3d4ec !important;
+}
+
+.atr-cbm-deuteranopia .border-danger-subtle {
+ border-color: #f5cdb0 !important;
+}
+
+.atr-cbm-deuteranopia .warning-banner {
+ border-color: #ee7733;
+ background: #fce5d3;
+}
+
+/* Protanopia (red-green): blue/orange palette, shifted */
+.atr-cbm-protanopia {
+ --bs-success: #0077bb;
+ --bs-success-rgb: 0, 119, 187;
+ --bs-danger: #cc6600;
+ --bs-danger-rgb: 204, 102, 0;
+}
+
+.atr-cbm-protanopia .atr-green {
+ color: #0077bb !important;
+}
+
+.atr-cbm-protanopia .atr-red {
+ color: #cc6600 !important;
+}
+
+.atr-cbm-protanopia .text-success {
+ color: #0077bb !important;
+}
+
+.atr-cbm-protanopia .text-danger {
+ color: #cc6600 !important;
+}
+
+.atr-cbm-protanopia .btn-success {
+ background-color: #0077bb;
+ border-color: #0077bb;
+}
+
+.atr-cbm-protanopia .btn-danger {
+ background-color: #cc6600;
+ border-color: #cc6600;
+}
+
+.atr-cbm-protanopia .btn-outline-success {
+ color: #0077bb;
+ border-color: #0077bb;
+}
+
+.atr-cbm-protanopia .btn-outline-danger {
+ color: #cc6600;
+ border-color: #cc6600;
+}
+
+.atr-cbm-protanopia .bg-success {
+ background-color: #0077bb !important;
+}
+
+.atr-cbm-protanopia .bg-danger {
+ background-color: #cc6600 !important;
+}
+
+.atr-cbm-protanopia .flash-success {
+ background-color: #d0e8f7;
+ border-color: #b3d4ec;
+ color: #004a73;
+}
+
+.atr-cbm-protanopia .flash-error {
+ background-color: #fae0c8;
+ border-color: #f0c9a0;
+ color: #7a3b00;
+}
+
+.atr-cbm-protanopia .alert-success {
+ background-color: #d0e8f7;
+ border-color: #b3d4ec;
+ color: #004a73;
+}
+
+.atr-cbm-protanopia .alert-danger {
+ background-color: #fae0c8;
+ border-color: #f0c9a0;
+ color: #7a3b00;
+}
+
+.atr-cbm-protanopia .bg-success-subtle {
+ background-color: #d0e8f7 !important;
+}
+
+.atr-cbm-protanopia .bg-danger-subtle {
+ background-color: #fae0c8 !important;
+}
+
+.atr-cbm-protanopia .text-success-emphasis {
+ color: #004a73 !important;
+}
+
+.atr-cbm-protanopia .text-danger-emphasis {
+ color: #7a3b00 !important;
+}
+
+.atr-cbm-protanopia .border-success-subtle {
+ border-color: #b3d4ec !important;
+}
+
+.atr-cbm-protanopia .border-danger-subtle {
+ border-color: #f0c9a0 !important;
+}
+
+.atr-cbm-protanopia .warning-banner {
+ border-color: #cc6600;
+ background: #fae0c8;
+}
+
+/* Tritanopia (blue-yellow): red/cyan palette */
+.atr-cbm-tritanopia {
+ --bs-success: #009988;
+ --bs-success-rgb: 0, 153, 136;
+ --bs-danger: #cc3366;
+ --bs-danger-rgb: 204, 51, 102;
+}
+
+.atr-cbm-tritanopia .atr-green {
+ color: #009988 !important;
+}
+
+.atr-cbm-tritanopia .atr-red {
+ color: #cc3366 !important;
+}
+
+.atr-cbm-tritanopia .text-success {
+ color: #009988 !important;
+}
+
+.atr-cbm-tritanopia .text-danger {
+ color: #cc3366 !important;
+}
+
+.atr-cbm-tritanopia .btn-success {
+ background-color: #009988;
+ border-color: #009988;
+}
+
+.atr-cbm-tritanopia .btn-danger {
+ background-color: #cc3366;
+ border-color: #cc3366;
+}
+
+.atr-cbm-tritanopia .btn-outline-success {
+ color: #009988;
+ border-color: #009988;
+}
+
+.atr-cbm-tritanopia .btn-outline-danger {
+ color: #cc3366;
+ border-color: #cc3366;
+}
+
+.atr-cbm-tritanopia .bg-success {
+ background-color: #009988 !important;
+}
+
+.atr-cbm-tritanopia .bg-danger {
+ background-color: #cc3366 !important;
+}
+
+.atr-cbm-tritanopia .flash-success {
+ background-color: #ccf0eb;
+ border-color: #a8e0d8;
+ color: #005c51;
+}
+
+.atr-cbm-tritanopia .flash-error {
+ background-color: #f5d3df;
+ border-color: #eab3c5;
+ color: #7a1a3a;
+}
+
+.atr-cbm-tritanopia .alert-success {
+ background-color: #ccf0eb;
+ border-color: #a8e0d8;
+ color: #005c51;
+}
+
+.atr-cbm-tritanopia .alert-danger {
+ background-color: #f5d3df;
+ border-color: #eab3c5;
+ color: #7a1a3a;
+}
+
+.atr-cbm-tritanopia .bg-success-subtle {
+ background-color: #ccf0eb !important;
+}
+
+.atr-cbm-tritanopia .bg-danger-subtle {
+ background-color: #f5d3df !important;
+}
+
+.atr-cbm-tritanopia .text-success-emphasis {
+ color: #005c51 !important;
+}
+
+.atr-cbm-tritanopia .text-danger-emphasis {
+ color: #7a1a3a !important;
+}
+
+.atr-cbm-tritanopia .border-success-subtle {
+ border-color: #a8e0d8 !important;
+}
+
+.atr-cbm-tritanopia .border-danger-subtle {
+ border-color: #eab3c5 !important;
+}
+
+.atr-cbm-tritanopia .warning-banner {
+ border-color: #cc3366;
+ background: #f5d3df;
+}
+
.atr-hide {
display: none;
}
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index d9465078..7527d4c6 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -78,6 +78,7 @@ class ReadAsFoundationCommitter(ReadAsGeneralPublic):
# self.checks = readers.checks.FoundationCommitter(read, self, data)
# self.releases = readers.releases.FoundationCommitter(read, self, data)
self.tokens = readers.tokens.FoundationCommitter(read, self, data)
+ self.user = readers.user.FoundationCommitter(read, self, data)
class ReadAsCommitteeParticipant(ReadAsFoundationCommitter): ...
@@ -152,6 +153,7 @@ class WriteAsFoundationCommitter(WriteAsGeneralPublic):
self.sbom = writers.sbom.FoundationCommitter(write, self, data)
self.ssh = writers.ssh.FoundationCommitter(write, self, data)
self.tokens = writers.tokens.FoundationCommitter(write, self, data)
+ self.user = writers.user.FoundationCommitter(write, self, data)
self.vote = writers.vote.FoundationCommitter(write, self, data)
@property
diff --git a/atr/storage/readers/__init__.py b/atr/storage/readers/__init__.py
index d767025a..d011ab90 100644
--- a/atr/storage/readers/__init__.py
+++ b/atr/storage/readers/__init__.py
@@ -18,5 +18,6 @@
import atr.storage.readers.checks as checks
import atr.storage.readers.releases as releases
import atr.storage.readers.tokens as tokens
+import atr.storage.readers.user as user
-__all__ = ["checks", "releases", "tokens"]
+__all__ = ["checks", "releases", "tokens", "user"]
diff --git a/atr/storage/readers/user.py b/atr/storage/readers/user.py
new file mode 100644
index 00000000..8132e995
--- /dev/null
+++ b/atr/storage/readers/user.py
@@ -0,0 +1,50 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Removing this will cause circular imports
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import atr.db as db
+ import atr.models.sql as sql
+ import atr.storage as storage
+
+
+class GeneralPublic:
+ def __init__(self, read: storage.Read, read_as: storage.ReadAsGeneralPublic, data: db.Session):
+ self.__read = read
+ self.__read_as = read_as
+ self.__data = data
+ self.__asf_uid = read.authorisation.asf_uid
+
+
+class FoundationCommitter(GeneralPublic):
+ def __init__(self, read: storage.Read, read_as: storage.ReadAsFoundationCommitter, data: db.Session):
+ super().__init__(read, read_as, data)
+ self.__read = read
+ self.__read_as = read_as
+ self.__data = data
+ self.__asf_uid = read.authorisation.asf_uid
+
+ async def user_preferences(self) -> sql.User | None:
+ asf_uid = self.__asf_uid
+ if asf_uid is None:
+ raise ValueError("Not authorized")
+ user = await self.__data.user(asf_uid=asf_uid).get()
+ return user
diff --git a/atr/storage/writers/__init__.py b/atr/storage/writers/__init__.py
index 9769fd38..67353037 100644
--- a/atr/storage/writers/__init__.py
+++ b/atr/storage/writers/__init__.py
@@ -30,6 +30,7 @@ import atr.storage.writers.revision as revision
import atr.storage.writers.sbom as sbom
import atr.storage.writers.ssh as ssh
import atr.storage.writers.tokens as tokens
+import atr.storage.writers.user as user
import atr.storage.writers.vote as vote
import atr.storage.writers.workflowstatus as workflowstatus
@@ -47,6 +48,7 @@ __all__ = [
"sbom",
"ssh",
"tokens",
+ "user",
"vote",
"workflowstatus",
]
diff --git a/atr/storage/writers/user.py b/atr/storage/writers/user.py
new file mode 100644
index 00000000..238d0be6
--- /dev/null
+++ b/atr/storage/writers/user.py
@@ -0,0 +1,119 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# TODO: Always raise and catch AccessError
+
+# Removing this will cause circular imports
+from __future__ import annotations
+
+import atr.db as db
+import atr.models.sql as sql
+import atr.storage as storage
+
+
+class GeneralPublic:
+ def __init__(
+ self,
+ write: storage.Write,
+ write_as: storage.WriteAsGeneralPublic,
+ data: db.Session,
+ ):
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+ self.__asf_uid = write.authorisation.asf_uid
+
+
+class FoundationCommitter(GeneralPublic):
+ def __init__(self, write: storage.Write, write_as: storage.WriteAsFoundationCommitter, data: db.Session):
+ super().__init__(write, write_as, data)
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+ asf_uid = write.authorisation.asf_uid
+ if asf_uid is None:
+ raise storage.AccessError("Not authorized")
+ self.__asf_uid = asf_uid
+
+ async def set_user_preferences(self, preferences: sql.UserPreferencesEntry):
+ user = await self.__data.user(self.__asf_uid).get()
+ if not user:
+ user = sql.User(asfuid=self.__asf_uid)
+ self.__data.add(user)
+ user.preferences = preferences
+ await self.__data.commit()
+ self.__write_as.append_to_audit_log(
+ action="preferences_update",
+ asf_uid=self.__asf_uid,
+ )
+
+
+class CommitteeParticipant(FoundationCommitter):
+ def __init__(
+ self,
+ write: storage.Write,
+ write_as: storage.WriteAsCommitteeParticipant,
+ data: db.Session,
+ committee_key: str,
+ ):
+ super().__init__(write, write_as, data)
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+ asf_uid = write.authorisation.asf_uid
+ if asf_uid is None:
+ raise storage.AccessError("Not authorized")
+ self.__asf_uid = asf_uid
+ self.__committee_key = committee_key
+
+
+class CommitteeMember(CommitteeParticipant):
+ def __init__(
+ self,
+ write: storage.Write,
+ write_as: storage.WriteAsCommitteeMember,
+ data: db.Session,
+ committee_key: str,
+ ):
+ super().__init__(write, write_as, data, committee_key)
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+ asf_uid = write.authorisation.asf_uid
+ if asf_uid is None:
+ raise storage.AccessError("Not authorized")
+ self.__asf_uid = asf_uid
+ self.__committee_key = committee_key
+
+
+class FoundationAdmin(CommitteeMember):
+ def __init__(
+ self,
+ write: storage.Write,
+ write_as: storage.WriteAsCommitteeAdmin,
+ data: db.Session,
+ committee_key: str,
+ ):
+ super().__init__(write, write_as, data, committee_key)
+ self.__write = write
+ self.__write_as = write_as
+ self.__data = data
+ asf_uid = write.authorisation.asf_uid
+ if asf_uid is None:
+ raise storage.AccessError("Not authorized")
+ self.__asf_uid = asf_uid
+ self.__committee_key = committee_key
diff --git a/atr/templates/includes/topnav.html b/atr/templates/includes/topnav.html
index d37ca64a..6867fa4d 100644
--- a/atr/templates/includes/topnav.html
+++ b/atr/templates/includes/topnav.html
@@ -130,6 +130,10 @@
<a class="dropdown-item" href="{{ as_url(get.tokens.tokens) }}"><i class="bi bi-key"></i>
API tokens</a>
</li>
+ <li>
+ <a class="dropdown-item" href="{{ as_url(get.user.preferences) }}"><i class="bi bi-gear"></i>
+ Preferences</a>
+ </li>
</ul>
</li>
{% endif %}
diff --git a/atr/templates/layouts/base.html b/atr/templates/layouts/base.html
index 0bc244c3..de2759a3 100644
--- a/atr/templates/layouts/base.html
+++ b/atr/templates/layouts/base.html
@@ -31,7 +31,7 @@
{% block head_extra %}
{% endblock head_extra %}
</head>
- <body class="{%- block body_class -%}{%- endblock body_class -%}">
+ <body class="{%- block body_class -%}{%- endblock body_class -%} {% if colour_blindness_mode and colour_blindness_mode.value != 'None' %}atr-cbm-{{ colour_blindness_mode.value | lower }}{% endif %}">
{% include "includes/topnav.html" %}
diff --git a/migrations/versions/0064_2026.04.07_2e6c48b8.py b/migrations/versions/0064_2026.04.07_2e6c48b8.py
new file mode 100644
index 00000000..42d3c9a7
--- /dev/null
+++ b/migrations/versions/0064_2026.04.07_2e6c48b8.py
@@ -0,0 +1,30 @@
+"""user table
+
+Revision ID: 0064_2026.04.07_2e6c48b8
+Revises: 0063_2026.03.25_9ae748a6
+Create Date: 2026-04-07 14:16:54.960772+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0064_2026.04.07_2e6c48b8"
+down_revision: str | None = "0063_2026.03.25_9ae748a6"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "user",
+ sa.Column("asfuid", sa.String(), nullable=False),
+ sa.Column("preferences", sa.JSON(), nullable=False),
+ sa.PrimaryKeyConstraint("asfuid", name=op.f("pk_user")),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("user")
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org