From 8ae8bc7a2463800ccc6bcbacd4aeec3581dbe6ac Mon Sep 17 00:00:00 2001 From: John Ahlroos Date: Thu, 30 Apr 2026 14:16:57 +0200 Subject: [PATCH] Add user color settings --- app/pages/counters.py | 2 +- app/pages/settings.py | 58 ++++++++++++ app/queries/crud.py | 42 ++++++++- app/streamlit_app.py | 18 +--- app/styles.py | 2 +- app/user.py | 11 +++ css/theme.css | 36 ++++++-- migrations/versions/20260430104824_colors_.py | 55 ++++++++++++ tests/conftest.py | 12 ++- tests/database/crud_db_test.py | 20 +++++ tests/database/stats_db_test.py | 88 +++++++++---------- tests/ui/settings_ui_test.py | 15 ++++ 12 files changed, 291 insertions(+), 68 deletions(-) create mode 100644 app/pages/settings.py create mode 100644 app/user.py create mode 100644 migrations/versions/20260430104824_colors_.py create mode 100644 tests/ui/settings_ui_test.py diff --git a/app/pages/counters.py b/app/pages/counters.py index 343b3ad..a4a2460 100644 --- a/app/pages/counters.py +++ b/app/pages/counters.py @@ -8,7 +8,7 @@ from enums import CounterType @dialog("Add New Counter", icon=":material/add_box:") def _add_counter(): - colors = crud.get_colors(1) + colors = crud.get_colors() with st.form(key="add_counter", border=False, clear_on_submit=True): title = st.text_input("Title:", key="new_counter_title") counter_type_name = st.selectbox("Type", options=[e.name for e in CounterType], key="new_counter_type") diff --git a/app/pages/settings.py b/app/pages/settings.py new file mode 100644 index 0000000..6cb4b49 --- /dev/null +++ b/app/pages/settings.py @@ -0,0 +1,58 @@ +import streamlit as st +from pandas.core.ops.docstrings import key +from pandas.io.formats.style import Styler + +import queries.crud as crud + +is_login_enabled = hasattr(st, 'user') and hasattr(st.user, 'is_logged_in') +is_logged_in = is_login_enabled and st.user.is_logged_in + +st.title("Settings") + +if hasattr(st.session_state, 'user_name'): + st.markdown(f"Currently logged in as **{st.session_state.user_name}**") + +st.header("Colors") +with st.container(key="settings-color-selector"): + palettes = crud.get_color_palettes() + selected = crud.get_color_palette() + for palette in palettes.iterrows(): + id = palette[1]['id'] + name = palette[1]['name'] + color1 = palette[1]['color1'] + color2 = palette[1]['color2'] + color3 = palette[1]['color3'] + color4 = palette[1]['color4'] + + if selected == id: + with st.container(horizontal=True, key=f"settings-color-selector-selected"): + st.button(f"{name} **(selected)**", disabled=True, width="stretch", icon=":material/radio_button_checked:") + st.html(f""" + +   +   +   +   + + """, width=400) + else: + with st.container(horizontal=True): + if st.button(f"{name}", width="stretch", icon=":material/radio_button_unchecked:"): + crud.set_color_palette(id) + st.rerun() + + st.html(f""" + +   +   +   +   + + """, width=400) + + +st.header("Actions") +with st.container(): + if is_logged_in: + if st.button("Logout", icon=":material/logout:", width="stretch"): + st.logout() \ No newline at end of file diff --git a/app/queries/crud.py b/app/queries/crud.py index 68d934c..3db247b 100644 --- a/app/queries/crud.py +++ b/app/queries/crud.py @@ -79,9 +79,47 @@ def get_counter(counter_id:int): return None -def get_colors(palette_id:int): +def get_color_palettes(): try: - return connection().query('''SELECT color1,color2,color3,color4,color5 FROM color_palettes WHERE id = :id''', params={'id': palette_id}) + return connection().query('SELECT * FROM color_palettes''') + except Exception as e: + logger.error(e) + return None + + +def get_color_palette(): + user_id = int(st.session_state.user_id) + try: + return int(connection().query('SELECT color_palette_id FROM users WHERE id = :id''', params={'id': user_id})['color_palette_id'][0]) + except Exception as e: + logger.error(e) + return None + + +def set_color_palette(palette_id:int): + user_id = int(st.session_state.user_id) + logger.info("Changing palette for user %d to %d", user_id, palette_id) + with connection().session as session: + try: + query = text('UPDATE users SET color_palette_id = :palette WHERE id = :user') + session.execute(query, { + 'palette': palette_id, + 'user': user_id + }) + except Exception as e: + logger.error(e) + session.rollback() + + +def get_colors(): + user_id = int(st.session_state.user_id) + try: + return connection().query(''' + SELECT color1,color2,color3,color4,color5 + FROM users u + LEFT JOIN color_palettes p ON p.id = u.color_palette_id + WHERE u.id = :id + ''', params={'id': user_id}) except Exception as e: logger.error(e) return None diff --git a/app/streamlit_app.py b/app/streamlit_app.py index 6876d1d..1474b04 100644 --- a/app/streamlit_app.py +++ b/app/streamlit_app.py @@ -4,18 +4,12 @@ import queries.user as user_queries from logger import init_logger from styles import init_styles +from user import init_user, is_login_enabled, is_logged_in init_logger() +init_user() init_styles() -is_login_enabled = hasattr(st, 'user') -is_logged_in = is_login_enabled and hasattr(st.user, 'is_logged_in') and st.user.is_logged_in - -if is_logged_in: - user_queries.set_user_in_session(st.user) -else: - st.session_state.user_id = 1 # default user - if is_login_enabled and not is_logged_in: with st.container(width="stretch", height="stretch", horizontal_alignment="center"): st.title("Daily Counter", width="stretch", text_alignment="center") @@ -27,11 +21,7 @@ if is_login_enabled and not is_logged_in: else: counters = st.Page("pages/counters.py", title="Counters", icon=":material/update:") stats = st.Page("pages/stats.py", title="Statistics", icon=":material/chart_data:") - logoutPage = st.Page(st.logout, title="Logout", icon=":material/logout:") - - pages = [counters, stats] - if is_login_enabled: - pages = pages + [logoutPage] - + settings = st.Page("pages/settings.py", title=" ", icon=":material/menu:") + pages = [counters, stats, settings] pg = st.navigation(position="top", pages=pages) pg.run() diff --git a/app/styles.py b/app/styles.py index aea7589..938fa24 100644 --- a/app/styles.py +++ b/app/styles.py @@ -8,7 +8,7 @@ def _load_css(filepath): def _load_color_selector_styles(): - colors = crud.get_colors(1) #FIXME Change to use user profile color palette + colors = crud.get_colors() for idx, c in enumerate(colors.keys()): css_color = '#' + colors[c][0] st.html(f""" diff --git a/app/user.py b/app/user.py new file mode 100644 index 0000000..079aa32 --- /dev/null +++ b/app/user.py @@ -0,0 +1,11 @@ +import queries.user as user_queries +import streamlit as st + +is_login_enabled = hasattr(st, 'user') and hasattr(st.user, 'is_logged_in') +is_logged_in = is_login_enabled and st.user.is_logged_in + +def init_user(): + if is_logged_in: + user_queries.set_user_in_session(st.user) + else: + st.session_state.user_id = 1 # default user \ No newline at end of file diff --git a/css/theme.css b/css/theme.css index cf3e648..0527b8a 100644 --- a/css/theme.css +++ b/css/theme.css @@ -58,15 +58,41 @@ div:has(> .stToolbarActions) { display: none; } .rc-overflow > .rc-overflow-item { - flex: 1; + flex:1; +} +.rc-overflow > .rc-overflow-item:nth-child(3) { + margin-left: auto; + flex: 0; } .rc-overflow > .rc-overflow-item:nth-child(3) > div { - margin-left: auto; - width: fit-content; + width: 33px; } -.rc-overflow > .rc-overflow-item:nth-child(3) > div > a { - padding: 0; +.rc-overflow > .rc-overflow-item:nth-child(3) a { + border-radius: 13px; } .rc-overflow > .rc-overflow-item-rest { display: none; } + +.st-key-settings-color-selector { + gap: 3px; +} +.settings-color-selector-colors > span { + display:inline-block; + width:2.5em; + height:2.5em; +} +.st-key-settings-color-selector button { + border: 0; + background-color: transparent; +} +.st-key-settings-color-selector button > div { + justify-content: left; +} + +.settings-color-selector-colors { + float: right; +} +.st-key-settings-color-selector-selected .settings-color-selector-colors > span { + opacity: 0.5; +} diff --git a/migrations/versions/20260430104824_colors_.py b/migrations/versions/20260430104824_colors_.py new file mode 100644 index 0000000..0e88871 --- /dev/null +++ b/migrations/versions/20260430104824_colors_.py @@ -0,0 +1,55 @@ +"""personalized colors + +Revision ID: 720abfadcd44 +Revises: d9faf8fb8642 +Create Date: 2026-04-30 10:48:24.595774 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '720abfadcd44' +down_revision: Union[str, Sequence[str], None] = 'd9faf8fb8642' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_table("color_palettes") + + color_palette = op.create_table( + "color_palettes", + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('name', sa.String, nullable=False), + sa.Column('color1', sa.String(6), nullable=False), + sa.Column('color2', sa.String(6), nullable=False), + sa.Column('color3', sa.String(6), nullable=False), + sa.Column('color4', sa.String(6), nullable=False), + sa.Column('color5', sa.String(6), nullable=False), + ) + + op.bulk_insert(color_palette, [ + {"name": "Flames", "color1": "F2F3AE", "color2": "EDD382", "color3": "FC9E4F", "color4": "FF521B", "color5": "020122"}, + {"name": "Water", "color1": "2B4141", "color2": "0EB1D2", "color3": "34E4EA", "color4": "8AB9B5", "color5": "C8C2AE"}, + {"name": "Nature", "color1": "181F1C", "color2": "274029", "color3": "315C2B", "color4": "60712F", "color5": "9EA93F"}, + {"name": "Mellow", "color1": "A3A380", "color2": "D6CE93", "color3": "EFEBCE", "color4": "D8A48F", "color5": "BB8588"}, + {"name": "Light Blue", "color1": "32292F", "color2": "99E1D9", "color3": "F0F7F4", "color4": "70ABAF", "color5": "705D56"} + ]) + + with op.batch_alter_table("users") as batch_op: + batch_op.add_column(sa.Column("color_palette_id", sa.Integer, nullable=False, server_default="1")) + batch_op.create_foreign_key("fk_users_color_palettes_id", + referent_table="color_palettes", + local_cols=["color_palette_id"], + remote_cols=["id"]) + + +def downgrade() -> None: + with op.batch_alter_table("users") as batch_op: + batch_op.drop_constraint("fk_users_color_palettes_id", type_="foreignkey") + batch_op.drop_column("color_palette_id") + + op.drop_column("color_palettes", "name") diff --git a/tests/conftest.py b/tests/conftest.py index de47b5a..a246392 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ import pytest from alembic import config from pytest_alembic.config import Config from streamlit.testing.v1 import AppTest +import streamlit as st logger = logging.getLogger(__name__) @@ -18,11 +19,20 @@ def setup_database(alembic_runner): @pytest.fixture def alembic_config() -> Config: - logging.info("Setting up alembic config") + logger.info("Setting up alembic config") alembic_cfg = config.Config(toml_file="pyproject.toml") alembic_cfg.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL", "")) return Config(alembic_config=alembic_cfg) +@pytest.fixture(autouse=True) +def user_config(): + logger.info("Setting up test user") + st.user = None + st.session_state.user_id = 1 + st.session_state.user_name = "Test User" + st.session_state.user_email = "test@test.local" + st.session_state.user_external_id = "111-2222-3333" + @pytest.fixture def app() -> AppTest: return AppTest.from_file("app/streamlit_app.py") diff --git a/tests/database/crud_db_test.py b/tests/database/crud_db_test.py index 4133a53..dd7beb4 100644 --- a/tests/database/crud_db_test.py +++ b/tests/database/crud_db_test.py @@ -14,6 +14,7 @@ def test_create_counter(): assert counters["type"][0] == CounterType.SIMPLE assert counters["color"][0] == '020122' + def test_remove_counter(): crud.create_counter("Test", CounterType.SIMPLE, "020122") assert len(crud.get_counters()) == 1 @@ -37,3 +38,22 @@ def test_increment_counter(): assert daily_stats["count"][0] == 2 +def test_get_color_palettes(): + palettes = crud.get_color_palettes() + assert len(palettes) == 5 + assert palettes['name'][0] == "Flames" + + +def test_get_user_colors(): + palettes = crud.get_color_palettes() + palette_id = crud.get_color_palette() + assert palette_id == 1 + assert palettes.loc[palettes['id'] == palette_id]["color1"][0] == 'F2F3AE' + + crud.set_color_palette(2) + + palettes = crud.get_color_palettes() + palette_id = crud.get_color_palette() + assert palette_id == 2 + assert palettes.loc[palettes['id'] == palette_id]["color1"][1] == '2B4141' + diff --git a/tests/database/stats_db_test.py b/tests/database/stats_db_test.py index 405f01c..86d6d67 100644 --- a/tests/database/stats_db_test.py +++ b/tests/database/stats_db_test.py @@ -15,14 +15,14 @@ def test_all_daily_stats(): crud.create_counter("Test2", CounterType.SIMPLE, "020122") with connection().session as session: query = text(""" - INSERT INTO entries (counter_id, "timestamp", increment) + INSERT INTO entries (counter_id, user_id, "timestamp", increment) VALUES - (1, date(), 1), - (1, date(date(), '-1 days'), 2), - (1, date(date(), '-3 days'), 3), - (2, date(), 2), - (2, date(date(), '-1 days'), 4), - (2, date(date(), '-3 days'), 6) + (1, 1, date(), 1), + (1, 1, date(date(), '-1 days'), 2), + (1, 1, date(date(), '-3 days'), 3), + (2, 1, date(), 2), + (2, 1, date(date(), '-1 days'), 4), + (2, 1, date(date(), '-3 days'), 6) """) session.execute(query) session.commit() @@ -41,11 +41,11 @@ def test_daily_stats(): crud.create_counter("Test", CounterType.SIMPLE, "020122") with connection().session as session: query = text(""" - INSERT INTO entries (counter_id, "timestamp", increment) + INSERT INTO entries (counter_id, user_id, "timestamp", increment) VALUES - (1, date(), 1), - (1, date(date(), '-1 days'), 2), - (1, date(date(), '-3 days'), 3) + (1, 1, date(), 1), + (1, 1, date(date(), '-1 days'), 2), + (1, 1, date(date(), '-3 days'), 3) """) session.execute(query) session.commit() @@ -62,14 +62,14 @@ def test_all_monthly_stats(): crud.create_counter("Test2", CounterType.SIMPLE, "020122") with connection().session as session: query = text(""" - INSERT INTO entries (counter_id, "timestamp", increment) + INSERT INTO entries (counter_id, user_id, "timestamp", increment) VALUES - (1, date(), 1), - (1, date(date(), '-1 month'), 2), - (1, date(date(), '-3 months'), 3), - (2, date(), 2), - (2, date(date(), '-2 months'), 4), - (2, date(date(), '-3 months'), 6) + (1, 1, date(), 1), + (1, 1, date(date(), 'start of month', '-1 month'), 2), + (1, 1, date(date(), 'start of month', '-3 months'), 3), + (2, 1, date(), 2), + (2, 1, date(date(), 'start of month', '-2 months'), 4), + (2, 1, date(date(), 'start of month', '-3 months'), 6) """) session.execute(query) session.commit() @@ -87,11 +87,11 @@ def test_monthly_stats(): crud.create_counter("Test", CounterType.SIMPLE, "020122") with connection().session as session: query = text(""" - INSERT INTO entries (counter_id, "timestamp", increment) + INSERT INTO entries (counter_id, user_id, "timestamp", increment) VALUES - (1, date(), 1), - (1, date(date(), '-1 months'), 2), - (1, date(date(), '-3 months'), 3) + (1, 1, date(), 1), + (1, 1, date(date(), '-1 months'), 2), + (1, 1, date(date(), '-3 months'), 3) """) session.execute(query) session.commit() @@ -108,14 +108,14 @@ def test_all_yearly_stats(): crud.create_counter("Test2", CounterType.SIMPLE, "020122") with connection().session as session: query = text(""" - INSERT INTO entries (counter_id, "timestamp", increment) + INSERT INTO entries (counter_id, user_id, "timestamp", increment) VALUES - (1, date(), 1), - (1, date(date(), '-1 year'), 2), - (1, date(date(), '-3 years'), 3), - (2, date(), 2), - (2, date(date(), '-2 years'), 4), - (2, date(date(), '-3 years'), 6) + (1, 1, date(), 1), + (1, 1, date(date(), '-1 year'), 2), + (1, 1, date(date(), '-3 years'), 3), + (2, 1, date(), 2), + (2, 1, date(date(), '-2 years'), 4), + (2, 1, date(date(), '-3 years'), 6) """) session.execute(query) session.commit() @@ -133,11 +133,11 @@ def test_yearly_stats(): crud.create_counter("Test", CounterType.SIMPLE, "020122") with connection().session as session: query = text(""" - INSERT INTO entries (counter_id, "timestamp", increment) + INSERT INTO entries (counter_id, user_id, "timestamp", increment) VALUES - (1, date(), 1), - (1, date(date(), '-1 years'), 2), - (1, date(date(), '-3 years'), 3) + (1, 1, date(), 1), + (1, 1, date(date(), '-1 years'), 2), + (1, 1, date(date(), '-3 years'), 3) """) session.execute(query) session.commit() @@ -154,14 +154,14 @@ def test_all_weekly_stats(): crud.create_counter("Test2", CounterType.SIMPLE, "020122") with connection().session as session: query = text(""" - INSERT INTO entries (counter_id, "timestamp", increment) + INSERT INTO entries (counter_id, user_id, "timestamp", increment) VALUES - (1, date(), 1), - (1, date(date(), '-7 days'), 2), - (1, date(date(), '-21 days'), 3), - (2, date(), 2), - (2, date(date(), '-14 days'), 4), - (2, date(date(), '-21 days'), 6) + (1, 1, date(), 1), + (1, 1, date(date(), '-7 days'), 2), + (1, 1, date(date(), '-21 days'), 3), + (2, 1, date(), 2), + (2, 1, date(date(), '-14 days'), 4), + (2, 1, date(date(), '-21 days'), 6) """) session.execute(query) session.commit() @@ -179,11 +179,11 @@ def test_weekly_stats(): crud.create_counter("Test", CounterType.SIMPLE, "020122") with connection().session as session: query = text(""" - INSERT INTO entries (counter_id, "timestamp", increment) + INSERT INTO entries (counter_id, user_id, "timestamp", increment) VALUES - (1, date(), 1), - (1, date(date(), '-7 days'), 2), - (1, date(date(), '-21 days'), 3) + (1, 1, date(), 1), + (1, 1, date(date(), '-7 days'), 2), + (1, 1, date(date(), '-21 days'), 3) """) session.execute(query) session.commit() diff --git a/tests/ui/settings_ui_test.py b/tests/ui/settings_ui_test.py new file mode 100644 index 0000000..5373dab --- /dev/null +++ b/tests/ui/settings_ui_test.py @@ -0,0 +1,15 @@ + +def test_change_color_palette(app): + app.run() + app.switch_page("pages/settings.py").run() + + assert app.button[0].disabled == True + assert app.button[0].label == "Flames **(selected)**" + + app.button[1].click().run() + + assert app.button[0].disabled == False + assert app.button[0].label == "Flames" + + assert app.button[1].disabled == True + assert app.button[1].label == "Water **(selected)**" \ No newline at end of file