Compare commits

...

57 Commits

Author SHA1 Message Date
Disco DeDisco
8807d31274 unified header_title template values across dashboard applet destination pages; styled &/ added applet titles across all applets
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-11 14:50:08 -04:00
Disco DeDisco
50ee983e27 found some lingering List references in the template dir; summarily changed to Note 2026-03-11 14:10:56 -04:00
Disco DeDisco
f45740d8b3 renamed List to Note everywhere thru-out project in preparation for complete overhaul of applet capabilities 2026-03-11 13:59:43 -04:00
Disco DeDisco
aa1cef6e7b new migration in apps.applets to seed wallet applet models; many expanded styles in wallet.js, chiefly concerned w. wallet-oriented FTs tbh; some intermittent Windows cache errors quashed in dash view ITs; apps.dash.views & .urls now support wallet applets; apps.lyric.models now discerns tithe coins (available for purchase soon); new styles across many scss files, again many concerning wallet applets but also applets more generally and also unorthodox media query parameters to make UX more usable; a slew of new wallet partials
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-11 00:58:24 -04:00
Disco DeDisco
791510b46d many styling fixes, esp. for both landscape & portrait mobile UX tooltips & navbar; core.settings now permits another device on local net to access dev server
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-10 14:11:53 -04:00
Disco DeDisco
fe6d2c5db1 stylistic changes primarily, esp. to page titles(new spans in header_text block, for instance)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-10 01:25:07 -04:00
Disco DeDisco
d2861077a4 tooltips now fully styled, appearing above applet container to avoid clipping issues; new methods added to apps.lyric.models.Token 2026-03-09 23:48:20 -04:00
Disco DeDisco
645b265c80 several user QoL styling improvements, incl. footer icon .active color painting 2026-03-09 22:42:30 -04:00
Disco DeDisco
382dd5958f full test suite passes; .gear-btn once again moved, this time to new file _applets.scss, along with generic applet styling attrs (removed from _base & .dash, respectively); _gameboard.scss in many ways mirrors particularities of _dash, but also feat. style attrs for the Game Kit applet consumables array; sacrificed btn in the latter now that applet dimensions defined on gameboard.html
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-09 21:52:54 -04:00
Disco DeDisco
47d84b6bf2 extensive refactor push to continue to liberate applets from dashboard; new _applets.html & .gear.html template partials for use across all -board views; all applets.html sections have been liberated into their own _applet-<applet-name>.html template partials in their respective templates/apps/*board/_partials/ dirs; gameboard.html & home.html greatly simplified; .gear-btn describes gear menu now, #id_<*board nickname>*gear IDs abandoned; as such, .gear-btn styling moved from _dashboard.scss to _base.scss; new applets.js file contains related initGearMenus scripts, which no longer waits for window reload; new apps.applets.utils file manages applet_context() fn; new gameboard.js file but currently empty (false start); updates across all sorts of ITs & dash- & gameboard FTs 2026-03-09 21:13:35 -04:00
Disco DeDisco
97601586c5 new applets app for cross-board usage of Applet() & UserApplet() models; dashboard migrations reset and apps reseeded w. new default specs; core.settings & many tests thru-out suite updated accordingly
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-09 16:08:28 -04:00
Disco DeDisco
2c445c0e76 replaced gear alt char or emoji w. font-awesome placeholder
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-09 15:09:41 -04:00
Disco DeDisco
a53dc41367 unified some styles, especially in #id_dash_gear menu
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-09 14:57:39 -04:00
Disco DeDisco
251b3bf778 commenced wallet styling; much of site now holds font-awesome placeholders until proprietary svg files apprpriated
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-09 14:40:34 -04:00
Disco DeDisco
bb2116ae9f stripe authentication error hopefully fixed w. woodpecker.ci .env var references
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-09 01:16:55 -04:00
Disco DeDisco
bd72135a2f full passing test suite w. new stripe integration across multiple project nodes; new gameboard django app; stripe in test mode on staging
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-09 01:07:16 -04:00
Disco DeDisco
ad0caa7c17 new migration to add wallet applet to dash db table; new views & html to accomodate 2026-03-08 15:27:24 -04:00
Disco DeDisco
076d75effe new apps/dashboard/wallet.html for stripe payment integration and user's consumables; nav added to _footer.html & also dynamic copyright year with django now Y template; new apps.dash.tests ITs & UTs reflect new wallet functionality in .urls & .views 2026-03-08 15:14:41 -04:00
Disco DeDisco
571f659b19 two new FTs, neither yet passing; test_wallet drives Stripe integration; test_gameboard drives Token system & apps.gameboard creation 2026-03-08 01:52:03 -05:00
Disco DeDisco
10dbd07cb9 fixed some breakpoint styling that prevented scrolling on mobile landscape windows
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-07 15:34:32 -05:00
Disco DeDisco
314da3e246 major styling additions & refinements; offloaded navbar from base.html into its own partial, core/_partials/_navbar.html, alongside new _footer.html; 0006 dash migrations fix 0003 & 0005 theme-switcher handling and rename more fluidly to palette; added remaining realm-swatches to palette applet choices & updated test_views accordingly
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-07 15:05:49 -05:00
Disco DeDisco
672de8a994 removed dead code from _applets.html
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-07 00:17:52 -05:00
Disco DeDisco
13940ca834 mobile dash layout provided; other styling inconsistencies corrected across views, scss & _applets.html template partial
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-07 00:05:32 -05:00
Disco DeDisco
b5d6912b26 styling & structure fixes to apps/dash/_parts/_applets.html, _dash.scss & _palette-picker.scss
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-06 23:12:56 -05:00
Disco DeDisco
02d0adef78 styling & subsequent testing bugs fixed across apps.dash.tests.ITs.test_views, functional_tests.test_dashboard,_dashboard.scss & apps/dash/_partials/_applets.html
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-06 22:31:10 -05:00
Disco DeDisco
4c502e40f8 fixed applet seeding in 0005 migration; many FTs & ITs now require authentication before they pass; New List & My Lists converted to dash applets; home.html offloaded and _applets.html onboarded w. these applets
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-06 21:34:43 -05:00
Disco DeDisco
17ee6c1f08 slight scss tweaks to palette applet
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-06 19:32:36 -05:00
Disco DeDisco
86e70b7256 took db-breaking migrations change out of 0003 and placed into new migration 0005 (grid_cols, grid_rows)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-06 19:22:30 -05:00
Disco DeDisco
9aea1ccb56 updated applet seed migration to include default applet sizes; other sundry styling refinements
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-06 19:14:53 -05:00
Disco DeDisco
42a9049c0a new migration in apps.dashboard for Applet grid_cols & grid_rows settings; test_models; complete overhaul of _dashboard.scss to containerize user scrolling; some new styling in _base.scss supports static window behind localized scrolling; new applet mgmt in apps.dashboard.admin; .views passes page_dashboard to home_page() FBV; keep an eye on IT apps.dashboard.tests.integrated.test_views.NewListTest.test_for_invalid_input_renders_list_template for intermittent caching errors
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-06 18:14:01 -05:00
Disco DeDisco
9936275443 significant expansion of scss styling, incl. new _dashboard.scss sheet & comprehensive primary btn theme synced w. user palette; changes to all other scss files; list.html & base.html retrofitted w. corresponding scss classes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-06 16:39:05 -05:00
Disco DeDisco
20c5f6f589 new _applets partial to govern applet list; home.html updated accordingly to incl partial; fixed seed migrations for palette convention from last commit; new text_view ITs & views to govern applet visibility/toggling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-05 16:08:40 -05:00
Disco DeDisco
c099479740 'theme_switcher,' 'theme-picker' & 'theme' renamed everywhere to simply 'palette'; new urls & views & their corresponding ITs ensure applet menu checkbox functionality 2026-03-05 14:45:55 -05:00
Disco DeDisco
ca835059c2 new migrations; new models in apps.dash for Applets and UserApplets; new ITs to match 2026-03-04 15:43:24 -05:00
Disco DeDisco
9548a2cd15 added locally hosted htmx dependency; updated base.html template & req's files accordingly; wrote new FT (failing) in test_dashboard that calls for this lib 2026-03-04 15:13:16 -05:00
Disco DeDisco
a218391ea5 100 percent test coverage achieved, patching a critical api bug in api.serializers and .views
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-04 13:40:19 -05:00
Disco DeDisco
fd59b02c3a new test_dashboard FT (part 1) for username applet on dashboard; apps/dashboard/home.html gained new applet section to support additions; new urlpatterns in apps.dash.urls; tweaks to .views, including the @login_required decorator and set_profile() FBV; new ITs in .tests.integrated.test_views
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-04 00:07:10 -05:00
Disco DeDisco
649bd39df9 didn't actually add any new files connected to lyric.templatetags
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-03 19:07:45 -05:00
Disco DeDisco
1c894f8ae6 username truncation functionality added
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-03 16:10:49 -05:00
Disco DeDisco
105b8f1e34 buttressed ansible playbook for automatic ssl certification
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-03 14:18:21 -05:00
Disco DeDisco
06f85d4c54 passed dummy values into compress command in Dockerfile for quick pipeline fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-02 22:23:58 -05:00
Disco DeDisco
b53c0b9849 small compress fixes to help serve scss on staging server and avoid persistent 500 errors
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-02 16:02:47 -05:00
Disco DeDisco
eebc355f95 themes initialized! many new partials and scss integrations across most templates; core.settings contains COMPRESS test fallback; apps.dashboard.views updated for new alerts and styling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-02 15:45:12 -05:00
Disco DeDisco
e142e5d4d7 new FT test_theme for theme switcher functionality; theme-switcher content added to home.html, several dashboard views & urls, all appropriate ITs & UTs; lyric user model saves theme (migrations run); django-compressor and django-libsass libraries added to dependencies 2026-03-02 13:57:03 -05:00
Disco DeDisco
143e81fc41 updated new username feature to api app; restructured api urlpatterns for more sustainable pahts
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-01 21:44:30 -05:00
Disco DeDisco
4aa63c74e2 added username (models.CharField) & searchable (models.BooleanField) to User model in lyric app; new ITs confirm functionality here; dashboard views now ensure that sharing a list w. an email address (as opposed to a username) neither confirms nor denies whether that email address has a registered account (ITs green)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-01 21:19:12 -05:00
Disco DeDisco
168c877970 refactored lists to have more descriptive urlpatterns; cascading changes across API, dashboard app & even FTs; restarted staging server db w. new migrations
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-22 23:56:29 -05:00
Disco DeDisco
94f3120add refactored to green: all references in urlpatterns thruout project to apps/ dir now skip it & point directly to the app contained w.in (i.e., not apps/lyric/ or apps/dashboard/, but lyric/ or dashboard/ now
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-22 22:08:34 -05:00
Disco DeDisco
a8c199b719 ensured in apps.dashboard.views, w. passing ITs in .tests.integrated.test_views & passing FT in functional_tests.test_sharing, passes only to recipients & owner 2026-02-22 21:50:25 -05:00
Disco DeDisco
17eb83c760 plugged share_list() FBV ability for user to share list w. self as recipient 2026-02-22 21:18:22 -05:00
Disco DeDisco
44c335b089 added superuser support in apps.lyric.admin & new manage.py cmd ensure_superuser; .tests.integrated.test_admin & .test_management_commands green
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-22 20:42:33 -05:00
Disco DeDisco
87ef197823 enabled redis alongside celery, but waiting on true caching functionality—flash messages will behave better w. cache_page after they rely on htmx library, not current full-page reload
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-21 23:13:23 -05:00
Disco DeDisco
a9e635f40e fix for functional_tests.test_login, which still relied on old mock logic, no longer in apps.lyric.views, but handled by celery in apps.lyric.tasks
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-21 22:03:03 -05:00
Disco DeDisco
04e28b96c8 offloaded some apps.lyric.views responsibilities to new Celery depend fn in .tasks; core.celery created for celery config; CELERY_BROKER_URL added to .settings & throughout project; some lyric view IT responsibility now accordingly covered by task UT domain
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-21 21:35:15 -05:00
Disco DeDisco
880fcb5bcf more consistent DRF installation in pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-20 16:58:55 -05:00
Disco DeDisco
9bdc358e59 commenced DRF efforts w. package installation, creation of apps.api, w. UTs & ITs to ensure core efficacy; core.settings & .urls changed to accomodate
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-20 16:37:48 -05:00
Disco DeDisco
ed21730a38 when clause fixes in .woodpecker.yaml
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-20 15:16:19 -05:00
142 changed files with 5310 additions and 560 deletions

View File

@@ -6,31 +6,48 @@ services:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
- name: redis
image: redis:7
steps: steps:
- name: test-UTs-n-ITs - name: test-UTs-n-ITs
image: python:3.13-slim image: python:3.13-slim
environment: environment:
DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test
CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1
commands: commands:
- pip install -r requirements.txt - pip install -r requirements.txt
- cd ./src - cd ./src
- python manage.py test apps - python manage.py test apps
when:
- event: push
- name: test-FTs - name: test-FTs
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
environment: environment:
HEADLESS: 1 HEADLESS: 1
CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1
STRIPE_SECRET_KEY:
from_secret: stripe_secret_key
STRIPE_PUBLISHABLE_KEY:
from_secret: stripe_publishable_key
commands: commands:
- pip install -r requirements.txt
- cd ./src - cd ./src
- python manage.py collectstatic --noinput - python manage.py collectstatic --noinput
- python manage.py test functional_tests - python manage.py test functional_tests
when:
- event: push
- name: screendumps - name: screendumps
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
when:
- status: failure
commands: commands:
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found" - cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
when:
- event: push
status: failure
- name: build-and-push - name: build-and-push
image: docker:cli image: docker:cli
@@ -43,7 +60,7 @@ steps:
- docker push gitea.earthmanrpg.me/discoman/gamearray:latest - docker push gitea.earthmanrpg.me/discoman/gamearray:latest
when: when:
- branch: main - branch: main
- event: push event: push
- name: deploy - name: deploy
image: alpine image: alpine
@@ -58,5 +75,5 @@ steps:
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh - ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
when: when:
- branch: main - branch: main
- event: push event: push

View File

@@ -15,6 +15,8 @@ RUN python manage.py collectstatic --noinput
ENV DJANGO_DEBUG_FALSE=1 ENV DJANGO_DEBUG_FALSE=1
RUN DJANGO_SECRET_KEY=build-dummy DJANGO_ALLOWED_HOST=localhost python manage.py compress
RUN adduser --uid 1234 nonroot RUN adduser --uid 1234 nonroot
USER nonroot USER nonroot

View File

@@ -114,6 +114,15 @@
POSTGRES_USER: gamearray POSTGRES_USER: gamearray
POSTGRES_PASSWORD: "{{ postgres_password }}" POSTGRES_PASSWORD: "{{ postgres_password }}"
- name: Start Redis container
community.docker.docker_container:
name: gamearray_redis
image: redis:7
state: started
restart_policy: unless-stopped
networks:
- name: gamearray_net
- name: Run container - name: Run container
community.docker.docker_container: community.docker.docker_container:
name: gamearray name: gamearray
@@ -124,13 +133,36 @@
DJANGO_DEBUG_FALSE: "1" DJANGO_DEBUG_FALSE: "1"
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}" DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}" DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
DJANGO_SUPERUSER_EMAIL: "{{ django_superuser_email }}"
DJANGO_SUPERUSER_PASSWORD: "{{ django_superuser_password }}"
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray" DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
MAILGUN_API_KEY: "{{ mailgun_api_key }}" MAILGUN_API_KEY: "{{ mailgun_api_key }}"
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
REDIS_URL: "redis://gamearray_redis:6379/1"
networks: networks:
- name: gamearray_net - name: gamearray_net
ports: ports:
127.0.0.1:8888:8888 127.0.0.1:8888:8888
- name: Start Celery worker container
community.docker.docker_container:
name: gamearray_celery
image: gitea.earthmanrpg.me/discoman/gamearray:latest
state: started
recreate: true
env:
DJANGO_DEBUG_FALSE: "1"
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
REDIS_URL: "redis://gamearray_redis:6379/1"
networks:
- name: gamearray_net
command: "python -m celery -A core worker -l info"
- name: Create static files directory - name: Create static files directory
ansible.builtin.file: ansible.builtin.file:
path: /var/www/gamearray/static path: /var/www/gamearray/static
@@ -149,6 +181,11 @@
container: gamearray container: gamearray
command: python manage.py migrate command: python manage.py migrate
- name: Ensure superuser exists
community.docker.docker_container_exec:
container: gamearray
command: python manage.py ensure_superuser
handlers: handlers:
- name: Restart nginx - name: Restart nginx
ansible.builtin.service: ansible.builtin.service:

View File

@@ -17,9 +17,22 @@ docker run -d --name gamearray \
-p 127.0.0.1:8888:8888 \ -p 127.0.0.1:8888:8888 \
"$IMAGE" "$IMAGE"
echo "==> Stopping old celery worker..."
docker stop gamearray_celery 2>/dev/null || true
docker rm gamearray_celery 2>/dev/null || true
echo "==> Starting new celery worker..."
docker run -d --name gamearray_celery \
--env-file /opt/gamearray/gamearray.env \
--network gamearray_net \
"$IMAGE" python -m celery -A core worker -l info
echo "==> Running migrations..." echo "==> Running migrations..."
docker exec gamearray python ./manage.py migrate docker exec gamearray python ./manage.py migrate
echo "==> Ensuring superuser exists..."
docker exec gamearray python manage.py ensure_superuser
echo "==> Copying static files..." echo "==> Copying static files..."
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/ sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/

View File

@@ -1,5 +1,12 @@
DJANGO_DEBUG_FALSE=1 DJANGO_DEBUG_FALSE=1
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }} DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
DJANGO_ALLOWED_HOST={{ django_allowed_host }} DJANGO_ALLOWED_HOST={{ django_allowed_host }}
DJANGO_SUPERUSER_EMAIL={{ django_superuser_email }}
DJANGO_SUPERUSER_PASSWORD={{ django_superuser_password }}
DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
MAILGUN_API_KEY={{ mailgun_api_key }} MAILGUN_API_KEY={{ mailgun_api_key }}
STRIPE_PUBLISHABLE_KEY={{ stripe_publishable_key }}
STRIPE_SECRET_KEY={{ stripe_secret_key }}
CELERY_BROKER_URL=redis://gamearray_redis:6379/0
REDIS_URL=redis://gamearray_redis:6379/1

View File

@@ -1,23 +1,42 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
33616230376431343735626631623932393166343538653732383533323436326335343463646664 38383061343764656262613934313230656462366163363263653462333338333863326338343838
6565373531623465613661613533376231373837326438300a393665613839646231633737313938 3664646437643462346636623231633639396239333532340a363338313839353734326238643735
64633035336663313163333634623732323537326363646132313136376131636666636538323066 39343237396433336436366430626332343666666461613636656433363838613432393539386266
3037373930303537320a313062646166353862633836373466316261363939633433663039323866 3237336434346333350a663530623334633438616135376437666631313064333735653633396461
62333739303662343836306538393734343830366336323265393138343438363533353166383031 31306163343838336465626663373661343839653037333235313361633335646337353339616333
32313461313137643039376237346633316466646136353038633861333031663164656233366634 35343233346562346236636364316265313936646235373866636333353866623161663935626637
38303363383130376264373861393863623330623733643135643461383132613339376633353031 31633864366339653930626365373237326531366632626337636163333266656434323063333365
32313863323039646534633733383661333361313832333830383066633130396239626661643264 38373437383261613439306666373764633737623466626235356465636365646337306534326535
65636335303339613432326533343337366261356632313639623634386633383836333733663536 36633866663161613632613434666134343465383663633165663330376535653537333763376232
39383361353530646166643531333535356636326535383534326237666638326137616162646261 61653265303134656338393033303834663630653064666134633638393235346631346461633030
65316466323335653932636338653565383038313531383638393839313736643739363037353230 35343332393961363361613661633633613262663231366236396663636239326534373134623762
35653632353531656435396663316537333133653632366437613339303033333536643937353166 30653139333134616236666238616466633733656633326331386138363839653566333434346534
64363037653733303332643931343362303261643432366531326262383465313965633064356338 63326539333461383265316332336333656365386531393630663537363365643061363263313738
31336333373665373035656533633864316139303934623030383934393434356334643962666163 37633564363533633762393736636333306433306534393539636231656162343562383232663932
33343739366336613263333764306365333566363536616662383733616237396563346132336633 62646339363266303564383438636636373661656465666663613863396639633732636635326166
38663239613339376335386233386330396634323033343332366130616162666339393861306336 39323738303338373466366236623665633538363134616565326665386564613735393638656630
35383566383831356530633130313732356331616164646132626665646235396635386237313538 31326431316163376132623064376634643737313864336464623431333834663361336133353838
38656631336261646530303761643334303937613036363766303637376262373466316431323731 32303635663261333732306137383133623134373363613837306637663566303634653863343766
38666462313639353131303134646434646135366136343361353932326165626666306361393431 33613936626362653466333537666462373633313038376565623363666631353162643634653730
62646238323265346263386363373462313766616333326366366461346436383064336535376339 30323532623261643136666237316561353038323265303930336364633731333533386563623133
31356566356336386262393831616631666233633930393263623563386265343237323133313832 31343965643336613933663431626435333235366639363334653065303434386165333739336632
3430363635363332303963316530663765613666306233376463 61363030376664643638653365626365623936623864666663326534343863613962616431376666
39363837386639393235316339323932326466616330303165613032663637616232656162653335
61613266376262626234383135306238313366346330656333383465383861663962653638303362
34353833646461383839386238626661346263363131643438343461393739336132386466373665
32646238633161363064666335626639653335306236613866333934646366323564306133396131
36343032623964316138386538333863363530396330646431373466646538663063326330663639
32323762356632336364333162336133336335623865323861663131626232633066643238333237
32343938353166353037316162653832663433343534626331633936633866356666653932656665
38396533356131326262633431653435306362633966383531356236396639376437396333616130
35666435393461316232323234653865346338326330623065373461323961393663306262313066
30313430353065616230356135333565333338373663643434353561363438656233383739663233
35653832353062396634613832353837333835636461616234343462626239636634613430373931
31656534343764643065643733326637343631356633653531313062633362663461313732633331
35626364393563373339636466346339383032383635303865306636623737343237333863353238
63306132396262656365323833323635633563653735366630313363386236613231346339643430
63396230353566633830383932666335373665356434656438336338633035653465613665613862
31663565653338376662323866613538363566306635333735646363363730646331306234353839
30346363393231623563646439623261643634663831313338393761343865303930373133633733
31656466303365316164396463373335396464643130643337656361333339653238333633373662
6539

View File

@@ -1,5 +1,5 @@
[staging] [staging]
staging.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd staging.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd letsencrypt_domain=staging.earthmanrpg.me
[production] [production]
www.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd www.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd

View File

@@ -1,6 +1,15 @@
server { server {
listen 80; listen 80;
server_name {{ django_allowed_host | replace(',', ' ')}}; server_name {{ django_allowed_host | replace(',', ' ')}};
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name {{ django_allowed_host | replace(',', ' ') }};
ssl_certificate /etc/letsencrypt/live/{{ letsencrypt_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ letsencrypt_domain }}/privkey.pem;
location /static/ { location /static/ {
alias /var/www/gamearray/static/; alias /var/www/gamearray/static/;
@@ -11,6 +20,6 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto https;
} }
} }

View File

@@ -7,8 +7,12 @@ coverage
cssselect==1.3.0 cssselect==1.3.0
dj-database-url dj-database-url
Django==6.0 Django==6.0
django-compressor
django-htmx
django-libsass
django-stubs==5.2.8 django-stubs==5.2.8
django-stubs-ext==5.2.8 django-stubs-ext==5.2.8
djangorestframework
gunicorn==23.0.0 gunicorn==23.0.0
h11==0.16.0 h11==0.16.0
idna==3.11 idna==3.11
@@ -17,11 +21,13 @@ outcome==1.3.0.post0
packaging==25.0 packaging==25.0
pycparser==2.23 pycparser==2.23
PySocks==1.7.1 PySocks==1.7.1
python-dotenv
requests==2.32.5 requests==2.32.5
selenium==4.39.0 selenium==4.39.0
sniffio==1.3.1 sniffio==1.3.1
sortedcontainers==2.4.0 sortedcontainers==2.4.0
sqlparse==0.5.5 sqlparse==0.5.5
stripe
trio==0.32.0 trio==0.32.0
trio-websocket==0.12.2 trio-websocket==0.12.2
types-PyYAML==6.0.12.20250915 types-PyYAML==6.0.12.20250915

View File

@@ -1,10 +1,17 @@
celery
cssselect==1.3.0 cssselect==1.3.0
Django==6.0 Django==6.0
dj-database-url dj-database-url
django-compressor
django-htmx
django-libsass
django-stubs==5.2.8 django-stubs==5.2.8
django-stubs-ext==5.2.8 django-stubs-ext==5.2.8
djangorestframework
gunicorn==23.0.0 gunicorn==23.0.0
lxml==6.0.2 lxml==6.0.2
psycopg2-binary psycopg2-binary
redis
requests==2.31.0 requests==2.31.0
stripe
whitenoise==6.11.0 whitenoise==6.11.0

0
src/apps/api/__init__.py Normal file
View File

View File

@@ -0,0 +1,32 @@
from rest_framework import serializers
from apps.dashboard.models import Item, Note
from apps.lyric.models import User
class ItemSerializer(serializers.ModelSerializer):
text = serializers.CharField()
def validate_text(self, value):
note = self.context["note"]
if note.item_set.filter(text=value).exists():
raise serializers.ValidationError("duplicate")
return value
class Meta:
model = Item
fields = ["id", "text"]
class NoteSerializer(serializers.ModelSerializer):
name = serializers.ReadOnlyField()
url = serializers.CharField(source="get_absolute_url", read_only=True)
items = ItemSerializer(many=True, read_only=True, source="item_set")
class Meta:
model = Note
fields = ["id", "name", "url", "items"]
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username"]

View File

View File

@@ -0,0 +1,115 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.dashboard.models import Item, Note
from apps.lyric.models import User
class BaseAPITest(TestCase):
# Helper fns
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user("test@example.com")
self.client.force_authenticate(user=self.user)
class NoteDetailAPITest(BaseAPITest):
def test_returns_note_with_items(self):
note = Note.objects.create(owner=self.user)
Item.objects.create(text="item 1", note=note)
Item.objects.create(text="item 2", note=note)
response = self.client.get(f"/api/notes/{note.id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["id"], str(note.id))
self.assertEqual(len(response.data["items"]), 2)
class NoteItemsAPITest(BaseAPITest):
def test_can_add_item_to_note(self):
note = Note.objects.create(owner=self.user)
response = self.client.post(
f"/api/notes/{note.id}/items/",
{"text": "a new item"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Item.objects.count(), 1)
self.assertEqual(Item.objects.first().text, "a new item")
def test_cannot_add_empty_item_to_note(self):
note = Note.objects.create(owner=self.user)
response = self.client.post(
f"/api/notes/{note.id}/items/",
{"text": ""},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(Item.objects.count(), 0)
def test_cannot_add_duplicate_item_to_note(self):
note = Note.objects.create(owner=self.user)
Item.objects.create(text="note item", note=note)
duplicate_response = self.client.post(
f"/api/notes/{note.id}/items/",
{"text": "note item"},
)
self.assertEqual(duplicate_response.status_code, 400)
self.assertEqual(Item.objects.count(), 1)
class NotesAPITest(BaseAPITest):
def test_get_returns_only_users_notes(self):
note1 = Note.objects.create(owner=self.user)
Item.objects.create(text="item 1", note=note1)
other_user = User.objects.create_user("other@example.com")
Note.objects.create(owner=other_user)
response = self.client.get("/api/notes/")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["id"], str(note1.id))
def test_post_creates_note_with_item(self):
response = self.client.post(
"/api/notes/",
{"text": "first item"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Note.objects.count(), 1)
self.assertEqual(Note.objects.first().owner, self.user)
self.assertEqual(Item.objects.first().text, "first item")
class UserSearchAPITest(BaseAPITest):
def test_returns_users_matching_username(self):
disco = User.objects.create_user("disco@example.com")
disco.username = "discoman"
disco.searchable = True
disco.save()
response = self.client.get("/api/users/?q=disc")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["username"], "discoman")
def test_non_searchable_users_are_excluded(self):
alice = User.objects.create_user("alice@example.com")
alice.username = "princessAli"
alice.save() # searchable defaults to False
response = self.client.get("/api/users/?q=prin")
self.assertEqual(response.data, [])
def test_response_does_not_include_email(self):
alice = User.objects.create_user("alice@example.com")
alice.username = "princessAli"
alice.searchable = True
alice.save()
response = self.client.get("/api/users/?q=prin")
self.assertNotIn("email", response.data[0])

View File

View File

@@ -0,0 +1,19 @@
from django.test import SimpleTestCase
from apps.api.serializers import ItemSerializer, NoteSerializer
class ItemSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = ItemSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "text"},
)
class NoteSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = NoteSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "name", "url", "items"},
)

11
src/apps/api/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path('notes/', views.NotesAPI.as_view(), name='api_notes'),
path('notes/<uuid:note_id>/', views.NoteDetailAPI.as_view(), name='api_note_detail'),
path('notes/<uuid:note_id>/items/', views.NoteItemsAPI.as_view(), name='api_note_items'),
path('users/', views.UserSearchAPI.as_view(), name='api_users'),
]

45
src/apps/api/views.py Normal file
View File

@@ -0,0 +1,45 @@
from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.response import Response
from apps.api.serializers import ItemSerializer, NoteSerializer, UserSerializer
from apps.dashboard.models import Item, Note
from apps.lyric.models import User
class NoteDetailAPI(APIView):
def get(self, request, note_id):
note = get_object_or_404(Note, id=note_id)
serializer = NoteSerializer(note)
return Response(serializer.data)
class NoteItemsAPI(APIView):
def post(self, request, note_id):
note = get_object_or_404(Note, id=note_id)
serializer = ItemSerializer(data=request.data, context={"note": note})
if serializer.is_valid():
serializer.save(note=note)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class NotesAPI(APIView):
def get(self, request):
notes = Note.objects.filter(owner=request.user)
serializer = NoteSerializer(notes, many=True)
return Response(serializer.data)
def post(self, request):
note = Note.objects.create(owner=request.user)
item = Item.objects.create(text=request.data.get("text", ""), note=note)
serializer = NoteSerializer(note)
return Response(serializer.data, status=201)
class UserSearchAPI(APIView):
def get(self, request):
q = request.query_params.get("q", "")
users = User.objects.filter(
username__icontains=q,
searchable=True,
)
serializer = UserSerializer(users, many=True)
return Response(serializer.data)

View File

11
src/apps/applets/admin.py Normal file
View File

@@ -0,0 +1,11 @@
from django.contrib import admin
from apps.applets.models import Applet, UserApplet
@admin.register(Applet)
class AppletAdmin(admin.ModelAdmin):
list_display = ['slug', 'name', 'default_visible', 'grid_cols', 'grid_rows']
list_editable = ['grid_cols', 'grid_rows']
admin.site.register(UserApplet)

5
src/apps/applets/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AppletsConfig(AppConfig):
name = 'apps.applets'

View File

@@ -0,0 +1,36 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Applet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(unique=True)),
('name', models.CharField(max_length=100)),
('context', models.CharField(choices=[('dashboard', 'Dashboard'), ('gameboard', 'Gameboard')], default='dashboard', max_length=20)),
('default_visible', models.BooleanField(default=True)),
('grid_cols', models.PositiveSmallIntegerField(default=12)),
('grid_rows', models.PositiveSmallIntegerField(default=3)),
],
),
migrations.CreateModel(
name='UserApplet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('visible', models.BooleanField(default=True)),
('applet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='applets.applet')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_applets', to=settings.AUTH_USER_MODEL)),
],
options={'unique_together': {('user', 'applet')}},
),
]

View File

@@ -0,0 +1,29 @@
from django.db import migrations
def seed_applets(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
for slug, name, cols, rows, context in [
('wallet', 'Wallet', 12, 3, 'dashboard'),
('new-list', 'New List', 9, 3, 'dashboard'),
('my-lists', 'My Lists', 3, 3, 'dashboard'),
('username', 'Username', 6, 3, 'dashboard'),
('palette', 'Palette', 6, 3, 'dashboard'),
('new-game', 'New Game', 4, 2, 'gameboard'),
('my-games', 'My Games', 4, 4, 'gameboard'),
('game-kit', 'Game Kit', 4, 2, 'gameboard'),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={'name': name, 'grid_cols': cols, 'grid_rows': rows, 'context': context},
)
class Migration(migrations.Migration):
dependencies = [
('applets', '0001_initial')
]
operations = [
migrations.RunPython(seed_applets, migrations.RunPython.noop)
]

View File

@@ -0,0 +1,37 @@
from django.db import migrations, models
def seed_wallet_applets(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
for slug, name, cols, rows in [
('wallet-balances', 'Wallet Balances', 3, 3),
('wallet-tokens', 'Wallet Tokens', 3, 3),
('wallet-payment', 'Payment Methods', 6, 2),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={'name': name, 'grid_cols': cols, 'grid_rows': rows, 'context': 'wallet'},
)
class Migration(migrations.Migration):
dependencies = [
('applets', '0002_seed_applets'),
]
operations = [
migrations.AlterField(
model_name='applet',
name='context',
field=models.CharField(
choices=[
('dashboard', 'Dashboard'),
('gameboard', 'Gameboard'),
('wallet', 'Wallet'),
],
default='dashboard',
max_length=20,
),
),
migrations.RunPython(seed_wallet_applets, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def rename_list_slugs(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='new-list').update(slug='new-note', name='New Note')
Applet.objects.filter(slug='my-lists').update(slug='my-notes', name='My Notes')
def reverse_rename_list_slugs(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='new-note').update(slug='new-list', name='New List')
Applet.objects.filter(slug='my-notes').update(slug='my-lists', name='My Lists')
class Migration(migrations.Migration):
dependencies = [
('applets', '0003_wallet_applets'),
]
operations = [
migrations.RunPython(rename_list_slugs, reverse_rename_list_slugs),
]

View File

View File

@@ -0,0 +1,36 @@
from django.db import models
class Applet(models.Model):
DASHBOARD = "dashboard"
GAMEBOARD = "gameboard"
WALLET = "wallet"
CONTEXT_CHOICES = [
(DASHBOARD, "Dashboard"),
(GAMEBOARD, "Gameboard"),
(WALLET, "Wallet"),
]
slug = models.SlugField(unique=True)
name = models.CharField(max_length=100)
context = models.CharField(max_length=20, choices=CONTEXT_CHOICES, default=DASHBOARD)
default_visible = models.BooleanField(default=True)
grid_cols = models.PositiveSmallIntegerField(default=12)
grid_rows = models.PositiveSmallIntegerField(default=3)
def __str__(self):
return self.name
class UserApplet(models.Model):
user = models.ForeignKey(
"lyric.User",
related_name="user_applets",
on_delete=models.CASCADE,
)
applet = models.ForeignKey(
Applet,
on_delete=models.CASCADE,
)
visible = models.BooleanField(default=True)
class Meta:
unique_together = ("user", "applet")

View File

@@ -0,0 +1,23 @@
const initGearMenus = () => {
document.querySelectorAll('.gear-btn').forEach(gear => {
const menuId = gear.dataset.menuTarget;
gear.addEventListener('click', (e) => {
e.stopPropagation();
const menu = document.getElementById(menuId);
if (!menu) return;
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', (e) => {
const menu = document.getElementById(menuId);
if (!menu || menu.style.display === 'none') return;
if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) {
menu.style.display = 'none';
}
});
})
};
document.addEventListener('DOMContentLoaded', initGearMenus);

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,65 @@
from django.db.utils import IntegrityError
from django.test import TestCase
from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.lyric.models import User
class AppletModelTest(TestCase):
def setUp(self):
self.applet = Applet.objects.create(
slug="my-applet", name="My Applet", default_visible=True
)
def test_applet_can_be_created(self):
self.assertEqual(Applet.objects.get(slug="my-applet"), self.applet)
def test_applet_slug_is_unique(self):
with self.assertRaises(IntegrityError):
Applet.objects.create(slug="my-applet", name="Second")
def test_applet_str(self):
self.assertEqual(str(self.applet), "My Applet")
def test_applet_grid_defaults(self):
self.assertEqual(self.applet.grid_cols, 12)
self.assertEqual(self.applet.grid_rows, 3)
class UserAppletModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
self.applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
def test_user_applet_links_user_to_applet(self):
ua = UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
self.assertIn(ua, self.user.user_applets.all())
def test_user_applet_unique_per_user_and_applet(self):
UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
with self.assertRaises(IntegrityError):
UserApplet.objects.create(user=self.user, applet=self.applet, visible=False)
class AppletContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
self.dash_applet = Applet.objects.create(slug="username", name="Username", context="dashboard")
self.game_applet = Applet.objects.create(slug="new-game", name="New Game", context="gameboard")
def test_filters_by_context(self):
result = applet_context(self.user, "dashboard")
slugs = [e["applet"].slug for e in result]
self.assertIn("username", slugs)
self.assertNotIn("new-game", slugs)
def test_defaults_to_applet_default_visible(self):
result = applet_context(self.user, "dashboard")
[entry] = [e for e in result if e["applet"].slug == "username"]
self.assertTrue(entry["visible"])
def test_respects_user_applet_visible_false(self):
UserApplet.objects.create(user=self.user, applet=self.dash_applet, visible=False)
result = applet_context(self.user, "dashboard")
[entry] = [e for e in result if e["applet"].slug == "username"]
self.assertFalse(entry["visible"])

11
src/apps/applets/utils.py Normal file
View File

@@ -0,0 +1,11 @@
from apps.applets.models import Applet, UserApplet
def applet_context(user, context):
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
applets = {a.slug: a for a in Applet.objects.filter(context=context)}
return [
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
for slug in applets
if slug in applets
]

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -1,3 +1 @@
from django.contrib import admin from django.contrib import admin
# Register your models here.

View File

@@ -2,8 +2,8 @@ from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from .models import Item from .models import Item
DUPLICATE_ITEM_ERROR = "You've already logged this to your list" DUPLICATE_ITEM_ERROR = "You've already logged this to your note"
EMPTY_ITEM_ERROR = "You can't have an empty list item" EMPTY_ITEM_ERROR = "You can't have an empty note item"
class ItemForm(forms.Form): class ItemForm(forms.Form):
text = forms.CharField( text = forms.CharField(
@@ -11,22 +11,22 @@ class ItemForm(forms.Form):
required=True, required=True,
) )
def save(self, for_list): def save(self, for_note):
return Item.objects.create( return Item.objects.create(
list=for_list, note=for_note,
text=self.cleaned_data["text"], text=self.cleaned_data["text"],
) )
class ExistingListItemForm(ItemForm): class ExistingNoteItemForm(ItemForm):
def __init__(self, for_list, *args, **kwargs): def __init__(self, for_note, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._for_list = for_list self._for_note = for_note
def clean_text(self): def clean_text(self):
text = self.cleaned_data["text"] text = self.cleaned_data["text"]
if self._for_list.item_set.filter(text=text).exists(): if self._for_note.item_set.filter(text=text).exists():
raise forms.ValidationError(DUPLICATE_ITEM_ERROR) raise forms.ValidationError(DUPLICATE_ITEM_ERROR)
return text return text
def save(self): def save(self):
return super().save(for_list=self._for_list) return super().save(for_note=self._for_note)

View File

@@ -1,6 +1,8 @@
# Generated by Django 6.0 on 2026-02-08 01:19 # Generated by Django 6.0 on 2026-02-23 04:30
import django.db.models.deletion import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -9,13 +11,16 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='List', name='List',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL)),
('shared_with', models.ManyToManyField(blank=True, related_name='shared_lists', to=settings.AUTH_USER_MODEL)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(

View File

@@ -1,21 +0,0 @@
# Generated by Django 6.0 on 2026-02-09 03:29
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='list',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,20 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0001_initial'),
]
operations = [
migrations.RenameModel(
old_name='List',
new_name='Note',
),
migrations.RenameField(
model_name='Item',
old_name='list',
new_name='note',
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 6.0 on 2026-02-18 18:13
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0002_list_owner'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='list',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_lists', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,10 +1,14 @@
import uuid
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
class List(models.Model):
class Note(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey( owner = models.ForeignKey(
"lyric.User", "lyric.User",
related_name="lists", related_name="notes",
blank=True, blank=True,
null=True, null=True,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -12,7 +16,7 @@ class List(models.Model):
shared_with = models.ManyToManyField( shared_with = models.ManyToManyField(
"lyric.User", "lyric.User",
related_name="shared_lists", related_name="shared_notes",
blank=True, blank=True,
) )
@@ -21,16 +25,15 @@ class List(models.Model):
return self.item_set.first().text return self.item_set.first().text
def get_absolute_url(self): def get_absolute_url(self):
return reverse("view_list", args=[self.id]) return reverse("view_note", args=[self.id])
class Item(models.Model): class Item(models.Model):
text = models.TextField(default="") text = models.TextField(default="")
list = models.ForeignKey(List, default=None, on_delete=models.CASCADE) note = models.ForeignKey(Note, default=None, on_delete=models.CASCADE)
class Meta: class Meta:
ordering = ("id",) ordering = ("id",)
unique_together = ("list", "text") unique_together = ("note", "text")
def __str__(self): def __str__(self):
return self.text return self.text

View File

@@ -2,8 +2,9 @@
const initialize = (inputSelector) => { const initialize = (inputSelector) => {
// console.log("initialize called!"); // console.log("initialize called!");
const textInput = document.querySelector(inputSelector); const textInput = document.querySelector(inputSelector);
if (!textInput) return;
textInput.oninput = () => { textInput.oninput = () => {
// console.log("oninput triggered"); // console.log("oninput triggered");
textInput.classList.remove("is-invalid"); textInput.classList.remove("is-invalid");
}; };
}; };

View File

@@ -0,0 +1,116 @@
const initWallet = () => {
let stripe, elements;
const addBtn = document.getElementById('id_add_payment_method');
const saveBtn = document.getElementById('id_save_payment_method');
const cancelBtn = document.getElementById('id_cancel_payment_method');
if (!addBtn) return;
const getCsrf = () => document.cookie.match(/csrftoken=([^;]+)/)[1];
addBtn.addEventListener('click', async () => {
const res = await fetch('/dashboard/wallet/setup-intent', {
method: 'POST',
headers: {'X-CSRFToken': getCsrf()},
});
const {client_secret, publishable_key} = await res.json();
stripe = Stripe(publishable_key);
elements = stripe.elements({clientSecret: client_secret});
const paymentEl = elements.create('payment');
paymentEl.mount('#id_stripe_payment_element');
saveBtn.hidden = false;
cancelBtn.hidden = false;
const section = addBtn.closest('section');
const rowPx = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize);
const updateRows = () => {
const sectionTop = section.getBoundingClientRect().top;
let maxBottom = sectionTop;
for (const child of section.children) {
if (child.hidden) continue;
maxBottom = Math.max(maxBottom, child.getBoundingClientRect().bottom);
}
const padBot = parseFloat(getComputedStyle(section).paddingBottom);
const rows = Math.ceil((maxBottom - sectionTop + padBot) / rowPx) + 1;
section.style.setProperty('--applet-rows', String(rows));
};
paymentEl.on('ready', () => {
updateRows();
const stripeContainer = document.getElementById('id_stripe_payment_element');
if (stripeContainer) {
const obs = new ResizeObserver(updateRows);
obs.observe(stripeContainer);
section._stripeObs = obs;
}
});
});
saveBtn.addEventListener('click', async () => {
const {error, setupIntent} = await stripe.confirmSetup({
elements,
redirect: 'if_required',
});
if (error) { console.error(error); return; }
const res = await fetch('/dashboard/wallet/save-payment-method', {
method: 'POST',
headers: {
'X-CSRFToken': getCsrf(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `payment_method_id=${setupIntent.payment_method}`,
});
const {last4, brand} = await res.json();
const pm = document.createElement('div');
pm.textContent = `${brand} ····${last4}`;
document.getElementById('id_payment_methods').appendChild(pm);
elements.getElement('payment').unmount();
elements = null;
stripe = null;
saveBtn.hidden = true;
cancelBtn.hidden = true;
const section = cancelBtn.closest('section');
section.style.setProperty('--applet-rows', '2');
if (section._stripeObs) { section._stripeObs.disconnect(); section._stripeObs = null; }
});
cancelBtn.addEventListener('click', () => {
if (elements) {
elements.getElement('payment').unmount();
elements = null;
stripe = null;
}
saveBtn.hidden = true;
cancelBtn.hidden = true;
const section = cancelBtn.closest('section');
section.style.setProperty('--applet-rows', '2');
if (section._stripeObs) { section._stripeObs.disconnect(); section._stripeObs = null; }
});
};
function initWalletTooltips() {
const portal = document.getElementById('id_tooltip_portal');
if (!portal) return;
document.querySelectorAll('.wallet-tokens .token').forEach(token => {
const tooltip = token.querySelector('.token-tooltip');
if (!tooltip) return;
token.addEventListener('mouseenter', () => {
const rect = token.getBoundingClientRect();
portal.innerHTML = tooltip.innerHTML;
portal.classList.add('active');
const halfW = portal.offsetWidth / 2;
const rawLeft = rect.left + rect.width / 2;
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
portal.style.left = Math.round(clampedLeft) + 'px';
portal.style.top = Math.round(rect.top) + 'px';
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
});
token.addEventListener('mouseleave', () => {
portal.classList.remove('active');
});
});
}
document.addEventListener('DOMContentLoaded', initWallet);
document.addEventListener('DOMContentLoaded', initWalletTooltips);

View File

@@ -3,39 +3,39 @@ from django.test import TestCase
from apps.dashboard.forms import ( from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR, DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR, EMPTY_ITEM_ERROR,
ExistingListItemForm, ExistingNoteItemForm,
ItemForm, ItemForm,
) )
from apps.dashboard.models import Item, List from apps.dashboard.models import Item, Note
class ItemFormTest(TestCase): class ItemFormTest(TestCase):
def test_form_save_handles_saving_to_a_list(self): def test_form_save_handles_saving_to_a_note(self):
mylist = List.objects.create() mynote = Note.objects.create()
form = ItemForm(data={"text": "do re mi"}) form = ItemForm(data={"text": "do re mi"})
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
new_item = form.save(for_list=mylist) new_item = form.save(for_note=mynote)
self.assertEqual(new_item, Item.objects.get()) self.assertEqual(new_item, Item.objects.get())
self.assertEqual(new_item.text, "do re mi") self.assertEqual(new_item.text, "do re mi")
self.assertEqual(new_item.list, mylist) self.assertEqual(new_item.note, mynote)
class ExistingListItemFormTest(TestCase): class ExistingNoteItemFormTest(TestCase):
def test_form_validation_for_blank_items(self): def test_form_validation_for_blank_items(self):
list_ = List.objects.create() note = Note.objects.create()
form = ExistingListItemForm(for_list=list_, data={"text": ""}) form = ExistingNoteItemForm(for_note=note, data={"text": ""})
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR]) self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
def test_form_validation_for_duplicate_items(self): def test_form_validation_for_duplicate_items(self):
list_ = List.objects.create() note = Note.objects.create()
Item.objects.create(list=list_, text="twins, basil") Item.objects.create(note=note, text="twins, basil")
form = ExistingListItemForm(for_list=list_, data={"text": "twins, basil"}) form = ExistingNoteItemForm(for_note=note, data={"text": "twins, basil"})
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [DUPLICATE_ITEM_ERROR]) self.assertEqual(form.errors["text"], [DUPLICATE_ITEM_ERROR])
def test_form_save(self): def test_form_save(self):
mylist = List.objects.create() mynote = Note.objects.create()
form = ExistingListItemForm(for_list=mylist, data={"text": "howdy"}) form = ExistingNoteItemForm(for_note=mynote, data={"text": "howdy"})
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
new_item = form.save() new_item = form.save()
self.assertEqual(new_item, Item.objects.get()) self.assertEqual(new_item, Item.objects.get())

View File

@@ -2,69 +2,69 @@ from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.test import TestCase from django.test import TestCase
from apps.dashboard.models import Item, List from apps.dashboard.models import Item, Note
from apps.lyric.models import User from apps.lyric.models import User
class ItemModelTest(TestCase): class ItemModelTest(TestCase):
def test_item_is_related_to_list(self): def test_item_is_related_to_note(self):
mylist = List.objects.create() mynote = Note.objects.create()
item = Item() item = Item()
item.list = mylist item.note = mynote
item.save() item.save()
self.assertIn(item, mylist.item_set.all()) self.assertIn(item, mynote.item_set.all())
def test_cannot_save_null_list_items(self): def test_cannot_save_null_note_items(self):
mylist = List.objects.create() mynote = Note.objects.create()
item = Item(list=mylist, text=None) item = Item(note=mynote, text=None)
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
item.save() item.save()
def test_cannot_save_empty_list_items(self): def test_cannot_save_empty_note_items(self):
mylist = List.objects.create() mynote = Note.objects.create()
item = Item(list=mylist, text="") item = Item(note=mynote, text="")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
item.full_clean() item.full_clean()
def test_duplicate_items_are_invalid(self): def test_duplicate_items_are_invalid(self):
mylist = List.objects.create() mynote = Note.objects.create()
Item.objects.create(list=mylist, text="jklol") Item.objects.create(note=mynote, text="jklol")
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
item = Item(list=mylist, text="jklol") item = Item(note=mynote, text="jklol")
item.full_clean() item.full_clean()
def test_still_can_save_same_item_to_different_lists(self): def test_still_can_save_same_item_to_different_notes(self):
list1 = List.objects.create() note1 = Note.objects.create()
list2 = List.objects.create() note2 = Note.objects.create()
Item.objects.create(list=list1, text="nojk") Item.objects.create(note=note1, text="nojk")
item = Item(list=list2, text="nojk") item = Item(note=note2, text="nojk")
item.full_clean() # should not raise item.full_clean() # should not raise
class ListModelTest(TestCase): class NoteModelTest(TestCase):
def test_get_absolute_url(self): def test_get_absolute_url(self):
mylist = List.objects.create() mynote = Note.objects.create()
self.assertEqual(mylist.get_absolute_url(), f"/apps/dashboard/{mylist.id}/") self.assertEqual(mynote.get_absolute_url(), f"/dashboard/note/{mynote.id}/")
def test_list_items_order(self): def test_note_items_order(self):
list1 = List.objects.create() note1 = Note.objects.create()
item1 = Item.objects.create(list=list1, text="i1") item1 = Item.objects.create(note=note1, text="i1")
item2 = Item.objects.create(list=list1, text="item 2") item2 = Item.objects.create(note=note1, text="item 2")
item3 = Item.objects.create(list=list1, text="3") item3 = Item.objects.create(note=note1, text="3")
self.assertEqual( self.assertEqual(
list(list1.item_set.all()), list(note1.item_set.all()),
[item1, item2, item3], [item1, item2, item3],
) )
def test_lists_can_have_owners(self): def test_notes_can_have_owners(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
mylist = List.objects.create(owner=user) mynote = Note.objects.create(owner=user)
self.assertIn(mylist, user.lists.all()) self.assertIn(mynote, user.notes.all())
def test_list_owner_is_optional(self): def test_note_owner_is_optional(self):
List.objects.create() Note.objects.create()
def test_list_name_is_first_item_text(self): def test_note_name_is_first_item_text(self):
list_ = List.objects.create() note = Note.objects.create()
Item.objects.create(list=list_, text="first item") Item.objects.create(note=note, text="first item")
Item.objects.create(list=list_, text="second item") Item.objects.create(note=note, text="second item")
self.assertEqual(list_.name, "first item") self.assertEqual(note.name, "first item")

View File

@@ -0,0 +1,79 @@
from unittest import mock
from django.test import TestCase
from apps.lyric.models import PaymentMethod, User
class SetupIntentViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.client.force_login(self.user)
def test_setup_intent_requires_login(self):
self.client.logout()
response = self.client.post("/dashboard/wallet/setup-intent")
self.assertRedirects(
response, "/?next=/dashboard/wallet/setup-intent",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_returns_client_secret(self, mock_stripe):
mock_stripe.Customer.create.return_value = mock.Mock(id="cus_test123")
mock_stripe.SetupIntent.create.return_value = mock.Mock(client_secret="seti_secret")
response = self.client.post("/dashboard/wallet/setup-intent")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["client_secret"], "seti_secret")
self.assertIn("publishable_key", response.json())
@mock.patch("apps.dashboard.views.stripe")
def test_reuses_existing_stripe_customer(self, mock_stripe):
self.user.stripe_customer_id = "cus_existing"
self.user.save()
mock_stripe.SetupIntent.create.return_value = mock.Mock(client_secret="seti_secret")
self.client.post("/dashboard/wallet/setup-intent")
mock_stripe.Customer.create.assert_not_called()
mock_stripe.SetupIntent.create.assert_called_once_with(customer="cus_existing")
class SavePaymentMethodViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.user.stripe_customer_id = "cus_test123"
self.user.save()
self.client.force_login(self.user)
def test_save_payment_method_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/save-payment-method", {"payment_method_id": "pm_test"}
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/save-payment-method",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_creates_payment_method_record(self, mock_stripe):
mock_stripe.PaymentMethod.retrieve.return_value = mock.Mock(
card=mock.Mock(last4="4242", brand="visa")
)
self.client.post(
"/dashboard/wallet/save-payment-method",
{"payment_method_id": "pm_test123"},
)
pm = PaymentMethod.objects.get(user=self.user)
self.assertEqual(pm.last4, "4242")
self.assertEqual(pm.brand, "visa")
@mock.patch("apps.dashboard.views.stripe")
def test_returns_json_with_last4_and_brand(self, mock_stripe):
mock_stripe.PaymentMethod.retrieve.return_value = mock.Mock(
card=mock.Mock(last4="4242", brand="visa")
)
response = self.client.post(
"/dashboard/wallet/save-payment-method",
{"payment_method_id": "pm_test123"},
)
data = response.json()
self.assertEqual(data["last4"], "4242")
self.assertEqual(data["brand"], "visa")

View File

@@ -1,18 +1,25 @@
import lxml.html import lxml.html
from unittest import skip
from django.test import TestCase from django.contrib.messages import get_messages
from django.test import override_settings, TestCase
from django.urls import reverse
from django.utils import html from django.utils import html
from apps.applets.models import Applet, UserApplet
from apps.dashboard.forms import ( from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR, DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR, EMPTY_ITEM_ERROR,
) )
from apps.dashboard.models import Item, List from apps.dashboard.models import Item, Note
from apps.lyric.models import User from apps.lyric.models import User
class HomePageTest(TestCase): class HomePageTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
def test_uses_home_template(self): def test_uses_home_template(self):
response = self.client.get('/') response = self.client.get('/')
self.assertTemplateUsed(response, 'apps/dashboard/home.html') self.assertTemplateUsed(response, 'apps/dashboard/home.html')
@@ -21,32 +28,36 @@ class HomePageTest(TestCase):
response = self.client.get('/') response = self.client.get('/')
parsed = lxml.html.fromstring(response.content) parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect('form[method=POST]') forms = parsed.cssselect('form[method=POST]')
self.assertIn("/apps/dashboard/new_list", [form.get("action") for form in forms]) self.assertIn("/dashboard/new_note", [form.get("action") for form in forms])
[form] = [form for form in forms if form.get("action") == "/apps/dashboard/new_list"] [form] = [form for form in forms if form.get("action") == "/dashboard/new_note"]
inputs = form.cssselect("input") inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs]) self.assertIn("text", [input.get("name") for input in inputs])
class NewListTest(TestCase): class NewNoteTest(TestCase):
def setUp(self):
user = User.objects.create(email="disco@test.io")
self.client.force_login(user)
def test_can_save_a_POST_request(self): def test_can_save_a_POST_request(self):
self. client.post("/apps/dashboard/new_list", data={"text": "A new list item"}) self.client.post("/dashboard/new_note", data={"text": "A new note item"})
self.assertEqual(Item.objects.count(), 1) self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get() new_item = Item.objects.get()
self.assertEqual(new_item.text, "A new list item") self.assertEqual(new_item.text, "A new note item")
def test_redirects_after_POST(self): def test_redirects_after_POST(self):
response = self.client.post("/apps/dashboard/new_list", data={"text": "A new list item"}) response = self.client.post("/dashboard/new_note", data={"text": "A new note item"})
new_list = List.objects.get() new_note = Note.objects.get()
self.assertRedirects(response, f"/apps/dashboard/{new_list.id}/") self.assertRedirects(response, f"/dashboard/note/{new_note.id}/")
# Post invalid input helper # Post invalid input helper
def post_invalid_input(self): def post_invalid_input(self):
return self.client.post("/apps/dashboard/new_list", data={"text": ""}) return self.client.post("/dashboard/new_note", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self): def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input() self.post_invalid_input()
self.assertEqual(Item.objects.count(), 0) self.assertEqual(Item.objects.count(), 0)
def test_for_invalid_input_renders_list_template(self): def test_for_invalid_input_renders_home_template(self):
response = self.post_invalid_input() response = self.post_invalid_input()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/home.html") self.assertTemplateUsed(response, "apps/dashboard/home.html")
@@ -55,15 +66,16 @@ class NewListTest(TestCase):
response = self.post_invalid_input() response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR)) self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
class ListViewTest(TestCase): @override_settings(COMPRESS_ENABLED=False)
def test_uses_list_template(self): class NoteViewTest(TestCase):
mylist = List.objects.create() def test_uses_note_template(self):
response = self.client.get(f"/apps/dashboard/{mylist.id}/") mynote = Note.objects.create()
self.assertTemplateUsed(response, "apps/dashboard/list.html") response = self.client.get(f"/dashboard/note/{mynote.id}/")
self.assertTemplateUsed(response, "apps/dashboard/note.html")
def test_renders_input_form(self): def test_renders_input_form(self):
mylist = List.objects.create() mynote = Note.objects.create()
url = f"/apps/dashboard/{mylist.id}/" url = f"/dashboard/note/{mynote.id}/"
response = self.client.get(url) response = self.client.get(url)
parsed = lxml.html.fromstring(response.content) parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect("form[method=POST]") forms = parsed.cssselect("form[method=POST]")
@@ -72,58 +84,58 @@ class ListViewTest(TestCase):
inputs = form.cssselect("input") inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs]) self.assertIn("text", [input.get("name") for input in inputs])
def test_displays_only_items_for_that_list(self): def test_displays_only_items_for_that_note(self):
# Given/Arrange # Given/Arrange
correct_list = List.objects.create() correct_note = Note.objects.create()
Item.objects.create(text="itemey 1", list=correct_list) Item.objects.create(text="itemey 1", note=correct_note)
Item.objects.create(text="itemey 2", list=correct_list) Item.objects.create(text="itemey 2", note=correct_note)
other_list = List.objects.create() other_note = Note.objects.create()
Item.objects.create(text="other list item", list=other_list) Item.objects.create(text="other note item", note=other_note)
# When/Act # When/Act
response = self.client.get(f"/apps/dashboard/{correct_list.id}/") response = self.client.get(f"/dashboard/note/{correct_note.id}/")
# Then/Assert # Then/Assert
self.assertContains(response, "itemey 1") self.assertContains(response, "itemey 1")
self.assertContains(response, "itemey 2") self.assertContains(response, "itemey 2")
self.assertNotContains(response, "other list item") self.assertNotContains(response, "other note item")
def test_can_save_a_POST_request_to_an_existing_list(self): def test_can_save_a_POST_request_to_an_existing_note(self):
other_list = List.objects.create() other_note = Note.objects.create()
correct_list = List.objects.create() correct_note = Note.objects.create()
self.client.post( self.client.post(
f"/apps/dashboard/{correct_list.id}/", f"/dashboard/note/{correct_note.id}/",
data={"text": "A new item for an existing list"}, data={"text": "A new item for an existing note"},
) )
self.assertEqual(Item.objects.count(), 1) self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get() new_item = Item.objects.get()
self.assertEqual(new_item.text, "A new item for an existing list") self.assertEqual(new_item.text, "A new item for an existing note")
self.assertEqual(new_item.list, correct_list) self.assertEqual(new_item.note, correct_note)
def test_POST_redirects_to_list_view(self): def test_POST_redirects_to_note_view(self):
other_list = List.objects.create() other_note = Note.objects.create()
correct_list = List.objects.create() correct_note = Note.objects.create()
response = self.client.post( response = self.client.post(
f"/apps/dashboard/{correct_list.id}/", f"/dashboard/note/{correct_note.id}/",
data={"text": "A new item for an existing list"}, data={"text": "A new item for an existing note"},
) )
self.assertRedirects(response, f"/apps/dashboard/{correct_list.id}/") self.assertRedirects(response, f"/dashboard/note/{correct_note.id}/")
# Post invalid input helper # Post invalid input helper
def post_invalid_input(self): def post_invalid_input(self):
mylist = List.objects.create() mynote = Note.objects.create()
return self.client.post(f"/apps/dashboard/{mylist.id}/", data={"text": ""}) return self.client.post(f"/dashboard/note/{mynote.id}/", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self): def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input() self.post_invalid_input()
self.assertEqual(Item.objects.count(), 0) self.assertEqual(Item.objects.count(), 0)
def test_for_invalid_input_renders_list_template(self): def test_for_invalid_input_renders_note_template(self):
response = self.post_invalid_input() response = self.post_invalid_input()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/list.html") self.assertTemplateUsed(response, "apps/dashboard/note.html")
def test_for_invalid_input_shows_error_on_page(self): def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input() response = self.post_invalid_input()
@@ -135,78 +147,319 @@ class ListViewTest(TestCase):
[input] = parsed.cssselect("input[name=text]") [input] = parsed.cssselect("input[name=text]")
self.assertIn("is-invalid", set(input.classes)) self.assertIn("is-invalid", set(input.classes))
def test_duplicate_item_validation_errors_end_up_on_lists_page(self): def test_duplicate_item_validation_errors_end_up_on_note_page(self):
list1 = List.objects.create() note1 = Note.objects.create()
Item.objects.create(list=list1, text="lorem ipsum") Item.objects.create(note=note1, text="lorem ipsum")
response = self.client.post( response = self.client.post(
f"/apps/dashboard/{list1.id}/", f"/dashboard/note/{note1.id}/",
data={"text": "lorem ipsum"}, data={"text": "lorem ipsum"},
) )
expected_error = html.escape(DUPLICATE_ITEM_ERROR) expected_error = html.escape(DUPLICATE_ITEM_ERROR)
self.assertContains(response, expected_error) self.assertContains(response, expected_error)
self.assertTemplateUsed(response, "apps/dashboard/list.html") self.assertTemplateUsed(response, "apps/dashboard/note.html")
self.assertEqual(Item.objects.all().count(), 1) self.assertEqual(Item.objects.all().count(), 1)
class MyListsTest(TestCase): class MyNotesTest(TestCase):
def test_my_lists_url_renders_my_lists_template(self): def test_my_notes_url_renders_my_notes_template(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.client.force_login(user) self.client.force_login(user)
response = self.client.get(f"/apps/dashboard/users/{user.id}/") response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertTemplateUsed(response, "apps/dashboard/my_lists.html") self.assertTemplateUsed(response, "apps/dashboard/my_notes.html")
def test_passes_correct_owner_to_template(self): def test_passes_correct_owner_to_template(self):
User.objects.create(email="wrongowner@example.com") User.objects.create(email="wrongowner@example.com")
correct_user = User.objects.create(email="a@b.cde") correct_user = User.objects.create(email="a@b.cde")
self.client.force_login(correct_user) self.client.force_login(correct_user)
response = self.client.get(f"/apps/dashboard/users/{correct_user.id}/") response = self.client.get(f"/dashboard/users/{correct_user.id}/")
self.assertEqual(response.context["owner"], correct_user) self.assertEqual(response.context["owner"], correct_user)
def test_list_owner_is_saved_if_user_is_authenticated(self): def test_note_owner_is_saved_if_user_is_authenticated(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.client.force_login(user) self.client.force_login(user)
self.client.post("/apps/dashboard/new_list", data={"text": "new item"}) self.client.post("/dashboard/new_note", data={"text": "new item"})
new_list = List.objects.get() new_note = Note.objects.get()
self.assertEqual(new_list.owner, user) self.assertEqual(new_note.owner, user)
def test_my_lists_redirects_if_not_logged_in(self): def test_my_notes_redirects_if_not_logged_in(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
response = self.client.get(f"/apps/dashboard/users/{user.id}/") response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertRedirects(response, "/") self.assertRedirects(response, "/")
def test_my_lists_returns_403_for_wrong_user(self): def test_my_notes_returns_403_for_wrong_user(self):
# create two users, login as user_a, request user_b's my_lists url # create two users, login as user_a, request user_b's my_notes url
user1 = User.objects.create(email="a@b.cde") user1 = User.objects.create(email="a@b.cde")
user2 = User.objects.create(email="wrongowner@example.com") user2 = User.objects.create(email="wrongowner@example.com")
self.client.force_login(user2) self.client.force_login(user2)
response = self.client.get(f"/apps/dashboard/users/{user1.id}/") response = self.client.get(f"/dashboard/users/{user1.id}/")
# assert 403 # assert 403
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
class ShareListTest(TestCase): class ShareNoteTest(TestCase):
def test_post_to_share_list_url_redirects_to_list(self): def test_post_to_share_note_url_redirects_to_note(self):
our_list = List.objects.create() our_note = Note.objects.create()
alice = User.objects.create(email="alice@example.com") alice = User.objects.create(email="alice@example.com")
response = self.client.post( response = self.client.post(
f"/apps/dashboard/{our_list.id}/share_list", f"/dashboard/note/{our_note.id}/share_note",
data={"recipient": "alice@example.com"}, data={"recipient": "alice@example.com"},
) )
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/") self.assertRedirects(response, f"/dashboard/note/{our_note.id}/")
def test_post_with_email_adds_user_to_shared_with(self): def test_post_with_email_adds_user_to_shared_with(self):
our_list = List.objects.create() our_note = Note.objects.create()
alice = User.objects.create(email="alice@example.com") alice = User.objects.create(email="alice@example.com")
self.client.post( self.client.post(
f"/apps/dashboard/{our_list.id}/share_list", f"/dashboard/note/{our_note.id}/share_note",
data={"recipient": "alice@example.com"}, data={"recipient": "alice@example.com"},
) )
self.assertIn(alice, our_list.shared_with.all()) self.assertIn(alice, our_note.shared_with.all())
def test_post_with_nonexistent_email_redirects_to_list(self): def test_post_with_nonexistent_email_redirects_to_note(self):
our_list = List.objects.create() our_note = Note.objects.create()
response = self.client.post( response = self.client.post(
f"/apps/dashboard/{our_list.id}/share_list", f"/dashboard/note/{our_note.id}/share_note",
data={"recipient": "nobody@example.com"}, data={"recipient": "nobody@example.com"},
) )
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/") self.assertRedirects(
response,
f"/dashboard/note/{our_note.id}/",
fetch_redirect_response=False,
)
def test_share_note_does_not_add_owner_as_recipient(self):
owner = User.objects.create(email="owner@example.com")
our_note = Note.objects.create(owner=owner)
self.client.force_login(owner)
self.client.post(reverse("share_note", args=[our_note.id]),
data={"recipient": "owner@example.com"})
self.assertNotIn(owner, our_note.shared_with.all())
@override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage')
def test_share_note_shows_privacy_safe_message(self):
our_note = Note.objects.create()
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
data={"recipient": "nobody@example.com"},
follow=True,
)
messages = list(get_messages(response.wsgi_request))
self.assertEqual(
str(messages[0]),
"An invite has been sent if that address is registered.",
)
class ViewAuthNoteTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="disco@example.com")
self.our_note = Note.objects.create(owner=self.owner)
def test_anonymous_user_is_redirected(self):
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_non_owner_non_shared_user_gets_403(self):
stranger = User.objects.create(email="stranger@example.com")
self.client.force_login(stranger)
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
self.assertEqual(response.status_code, 403)
def test_shared_with_user_can_access_note(self):
guest = User.objects.create(email="guest@example.com")
self.our_note.shared_with.add(guest)
self.client.force_login(guest)
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
self.assertEqual(response.status_code, 200)
@override_settings(COMPRESS_ENABLED=False)
class SetPaletteTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
self.client.force_login(self.user)
self.url = reverse("home")
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
def test_anonymous_user_is_redirected_home(self):
response = self.client.post("/dashboard/set_palette")
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_set_palette_updates_user_palette(self):
User.objects.filter(pk=self.user.pk).update(palette="palette-sheol")
self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-default")
def test_locked_palette_is_rejected(self):
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-nirvana"})
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-default")
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_set_palette_redirects_home(self):
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_dashboard_contains_set_palette_form(self):
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect('form[action="/dashboard/set_palette"]')
self.assertEqual(len(forms), 1)
def test_active_palette_swatch_has_active_class(self):
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
[active] = parsed.cssselect(".swatch.active")
self.assertIn("palette-default", active.classes)
def test_locked_palettes_are_not_forms(self):
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
locked = parsed.cssselect(".swatch.locked")
expected_locked = [p for p in response.context["palettes"] if p["locked"]]
self.assertEqual(len(locked), len(expected_locked))
# they mustn't be button els
for swatch in locked:
self.assertNotEqual(swatch.tag, "button")
def test_palette_picker_count_matches_context(self):
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
swatches = parsed.cssselect(".swatch")
self.assertEqual(len(swatches), len(response.context["palettes"]))
@override_settings(COMPRESS_ENABLED=False)
class ProfileViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="discoman@example.com")
self.client.force_login(self.user)
def test_post_username_saves_to_user(self):
self.client.post("/dashboard/set_profile", data={"username": "discoman"})
self.user.refresh_from_db()
self.assertEqual(self.user.username, "discoman")
def test_post_username_requires_login(self):
self.client.logout()
response = self.client.post("/dashboard/set_profile", data={"username": "somnambulist"})
self.assertRedirects(response, "/?next=/dashboard/set_profile", fetch_redirect_response=False)
def test_dash_renders_username_applet(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[applet] = parsed.cssselect("#id_applet_username")
self.assertIn("@", applet.text_content())
[input_el] = parsed.cssselect("#id_new_username")
self.assertEqual("", input_el.get("value"))
def test_dash_shows_display_name_in_applet(self):
self.user.username = "discoman"
self.user.save()
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[username_input] = parsed.cssselect("#id_new_username")
self.assertEqual("discoman", username_input.get("value"))
class ToggleDashAppletsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
self.url = reverse("toggle_applets")
def test_unauthenticated_user_is_redirected(self):
self.client.logout()
response = self.client.post(self.url)
self.assertRedirects(
response, f"/?next={self.url}", fetch_redirect_response=False
)
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
self.client.post(self.url, {"applets": ["username"]})
ua = UserApplet.objects.get(user=self.user, applet=self.palette_applet)
self.assertFalse(ua.visible)
def test_redirects_on_normal_post(self):
response = self.client.post(
self.url, {"applets": ["username", "palette"]}
)
self.assertRedirects(response, reverse("home"), fetch_redirect_response=False)
def test_returns_200_on_htmx_post(self):
response = self.client.post(
self.url,
{"applets": ["username", "palette"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_htmx_post_renders_visible_applets_only(self):
response = self.client.post(
self.url,
{"applets": ["username"]},
HTTP_HX_REQUEST="true",
)
parsed = lxml.html.fromstring(response.content)
self.assertEqual(len(parsed.cssselect("#id_applet_username")), 1)
self.assertEqual(len(parsed.cssselect("#id_applet_palette")), 0)
def test_toggle_applets_does_not_affect_gameboard_applets(self):
game_applet, _ = Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
self.client.post(self.url, {"applets": ["username", "palette"]})
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=game_applet). exists())
class AppletVisibilityContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
UserApplet.objects.create(user=self.user, applet=self.palette_applet, visible=False)
def test_dash_reflects_user_applet_visibility(self):
response = self.client.get("/")
applet_map = {entry["applet"].slug: entry["visible"] for entry in response.context["applets"]}
self.assertFalse(applet_map["palette"])
self.assertTrue(applet_map["username"])
class FooterNavTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
def test_footer_nav_present_on_dashboard(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[nav] = parsed.cssselect("#id_footer_nav")
self.assertIsNotNone(nav)
def test_footer_nav_has_dashboard_link(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[nav] = parsed.cssselect("#id_footer_nav")
links = [a.get("href") for a in nav.cssselect("a")]
self.assertIn("/", links)
def test_footer_nav_has_gameboard_link(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[nav] = parsed.cssselect("#id_footer_nav")
links = [a.get("href") for a in nav.cssselect("a")]
self.assertIn("/gameboard/", links)
class WalletAppletTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
response = self.client.get("/")
self.parsed = lxml.html.fromstring(response.content)
def test_wallet_applet_present_on_dash(self):
[_] = self.parsed.cssselect("#id_applet_wallet")
def test_wallet_applet_has_manage_link(self):
[link] = self.parsed.cssselect("#id_applet_wallet a.wallet-manage-link")
self.assertEqual(link.get("href"), "/dashboard/wallet/")

View File

@@ -0,0 +1,142 @@
import lxml.html
from django.test import override_settings, TestCase
from apps.applets.models import Applet, UserApplet
from apps.lyric.models import Token, User, Wallet
@override_settings(COMPRESS_ENABLED=False)
class WalletViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.client.force_login(self.user)
response = self.client.get("/dashboard/wallet/")
self.parsed = lxml.html.fromstring(response.content)
def test_wallet_page_requires_login(self):
self.client.logout()
response = self.client.get("/dashboard/wallet/")
self.assertRedirects(
response, "/?next=/dashboard/wallet/", fetch_redirect_response=False
)
def test_wallet_page_renders(self):
[el] = self.parsed.cssselect("#id_writs_balance")
self.assertEqual(el.text_content().strip(), "144")
def test_wallet_page_shows_esteem_balance(self):
[el] = self.parsed.cssselect("#id_esteem_balance")
self.assertEqual(el.text_content().strip(), "0")
def test_wallet_page_shows_coin_on_a_string(self):
[_] = self.parsed.cssselect("#id_coin_on_a_string")
def test_wallet_page_shows_free_token(self):
[_] = self.parsed.cssselect("#id_free_token_0")
def test_wallet_page_shows_payment_methods_section(self):
[_] = self.parsed.cssselect("#id_add_payment_method")
def test_wallet_page_shows_stripe_payment_element(self):
[_] = self.parsed.cssselect("#id_stripe_payment_element")
def test_wallet_page_shows_tithe_token_shop(self):
[_] = self.parsed.cssselect("#id_tithe_token_shop")
def test_tithe_token_shop_shows_bundle(self):
bundles = self.parsed.cssselect("#id_tithe_token_shop .token-bundle")
self.assertGreater(len(bundles), 0)
@override_settings(COMPRESS_ENABLED=False)
class WalletViewAppletContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="walletctx@test.io")
Applet.objects.get_or_create(
slug="wallet-balances",
defaults={"name": "Wallet Balances", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
)
Applet.objects.get_or_create(
slug="wallet-tokens",
defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
)
Applet.objects.get_or_create(
slug="wallet-payment",
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 2, "context": "wallet"},
)
self.client.force_login(self.user)
def test_wallet_view_passes_applets_context(self):
response = self.client.get("/dashboard/wallet/")
slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertIn("wallet-balances", slugs)
self.assertIn("wallet-tokens", slugs)
self.assertIn("wallet-payment", slugs)
def test_wallet_page_renders_applets_container(self):
response = self.client.get("/dashboard/wallet/")
parsed = lxml.html.fromstring(response.content)
[_] = parsed.cssselect("#id_wallet_applets_container")
def test_wallet_page_renders_gear_button(self):
response = self.client.get("/dashboard/wallet/")
parsed = lxml.html.fromstring(response.content)
[_] = parsed.cssselect(".gear-btn")
@override_settings(COMPRESS_ENABLED=False)
class ToggleWalletAppletsTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="wallettoggle@test.io")
self.balances = Applet.objects.get_or_create(
slug="wallet-balances",
defaults={"name": "Wallet Balances", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
)[0]
self.tokens = Applet.objects.get_or_create(
slug="wallet-tokens",
defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
)[0]
Applet.objects.get_or_create(
slug="wallet-payment",
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 2, "context": "wallet"},
)
self.client.force_login(self.user)
def test_toggle_requires_login(self):
self.client.logout()
response = self.client.post("/dashboard/wallet/toggle-applets", {})
self.assertRedirects(
response, "/?next=/dashboard/wallet/toggle-applets",
fetch_redirect_response=False,
)
def test_toggle_redirects_to_wallet(self):
response = self.client.post(
"/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}
)
self.assertRedirects(response, "/dashboard/wallet/", fetch_redirect_response=False)
def test_toggle_hides_unchecked_applet(self):
self.client.post(
"/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}
)
ua = UserApplet.objects.get(user=self.user, applet=self.tokens)
self.assertFalse(ua.visible)
def test_toggle_shows_checked_applet(self):
UserApplet.objects.create(user=self.user, applet=self.balances, visible=False)
self.client.post(
"/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}
)
ua = UserApplet.objects.get(user=self.user, applet=self.balances)
self.assertTrue(ua.visible)
def test_toggle_htmx_returns_container_partial(self):
response = self.client.post(
"/dashboard/wallet/toggle-applets",
{"applets": ["wallet-balances"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "id_wallet_applets_container")

View File

@@ -0,0 +1,9 @@
from datetime import date
from django.test import SimpleTestCase
from django.template.loader import render_to_string
class FooterTemplateTest(SimpleTestCase):
def test_footer_shows_current_year(self):
rendered = render_to_string("core/_partials/_footer.html")
self.assertIn(str(date.today().year), rendered)

View File

@@ -2,8 +2,15 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('new_list', views.new_list, name='new_list'), path('new_note', views.new_note, name='new_note'),
path('<int:list_id>/', views.view_list, name='view_list'), path('note/<uuid:note_id>/', views.view_note, name='view_note'),
path('users/<uuid:user_id>/', views.my_lists, name='my_lists'), path('note/<uuid:note_id>/share_note', views.share_note, name="share_note"),
path('<int:list_id>/share_list', views.share_list, name="share_list"), path('set_palette', views.set_palette, name='set_palette'),
path('set_profile', views.set_profile, name='set_profile'),
path('users/<uuid:user_id>/', views.my_notes, name='my_notes'),
path('toggle_applets', views.toggle_applets, name="toggle_applets"),
path('wallet/', views.wallet, name='wallet'),
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
] ]

View File

@@ -1,48 +1,201 @@
from django.http import HttpResponseForbidden import stripe
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from .forms import ExistingListItemForm, ItemForm from django.views.decorators.csrf import ensure_csrf_cookie
from .models import Item, List
from apps.lyric.models import User from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.dashboard.forms import ExistingNoteItemForm, ItemForm
from apps.dashboard.models import Item, Note
from apps.lyric.models import PaymentMethod, Token, User, Wallet
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
UNLOCKED_PALETTES = frozenset(["palette-default"])
PALETTES = [
{"name": "palette-default", "label": "Earthman", "locked": False},
{"name": "palette-nirvana", "label": "Nirvana", "locked": True},
{"name": "palette-sheol", "label": "Sheol", "locked": True},
{"name": "palette-inferno", "label": "Inferno", "locked": True},
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
{"name": "palette-celestia", "label": "Celestia", "locked": True},
]
def _recent_notes(user, limit=3):
return (
Note
.objects
.filter(Q(owner=user) | Q(shared_with=user))
.annotate(last_item=Max('item__id'))
.order_by('-last_item')
.distinct()[:limit]
)
def home_page(request): def home_page(request):
return render(request, "apps/dashboard/home.html", {"form": ItemForm()}) context = {
"form": ItemForm(),
"palettes": PALETTES,
"page_class": "page-dashboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "dashboard")
context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/dashboard/home.html", context)
def new_list(request): def new_note(request):
form = ItemForm(data=request.POST) form = ItemForm(data=request.POST)
if form.is_valid(): if form.is_valid():
nulist = List.objects.create() nunote = Note.objects.create()
if request.user.is_authenticated: if request.user.is_authenticated:
nulist.owner = request.user nunote.owner = request.user
nulist.save() nunote.save()
form.save(for_list=nulist) form.save(for_note=nunote)
return redirect(nulist) return redirect(nunote)
else: else:
return render(request, "apps/dashboard/home.html", {"form": form}) context = {
"form": form,
"palettes": PALETTES,
"page_class": "page-dashboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "dashboard")
context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/dashboard/home.html", context)
def view_list(request, list_id): def view_note(request, note_id):
our_list = List.objects.get(id=list_id) our_note = Note.objects.get(id=note_id)
form = ExistingListItemForm(for_list=our_list)
if our_note.owner:
if not request.user.is_authenticated:
return redirect("/")
if request.user != our_note.owner and request.user not in our_note.shared_with.all():
return HttpResponseForbidden()
form = ExistingNoteItemForm(for_note=our_note)
if request.method == "POST": if request.method == "POST":
form = ExistingListItemForm(for_list=our_list, data=request.POST) form = ExistingNoteItemForm(for_note=our_note, data=request.POST)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect(our_list) return redirect(our_note)
return render(request, "apps/dashboard/list.html", {"list": our_list, "form": form}) return render(request, "apps/dashboard/note.html", {"note": our_note, "form": form})
def my_lists(request, user_id): def my_notes(request, user_id):
owner = User.objects.get(id=user_id) owner = User.objects.get(id=user_id)
if not request.user.is_authenticated: if not request.user.is_authenticated:
return redirect("/") return redirect("/")
if request.user.id != owner.id: if request.user.id != owner.id:
return HttpResponseForbidden() return HttpResponseForbidden()
return render(request, "apps/dashboard/my_lists.html", {"owner": owner}) return render(request, "apps/dashboard/my_notes.html", {"owner": owner})
def share_list(request, list_id): def share_note(request, note_id):
our_list = List.objects.get(id=list_id) our_note = Note.objects.get(id=note_id)
try: try:
recipient = User.objects.get(email=request.POST["recipient"]) recipient = User.objects.get(email=request.POST["recipient"])
our_list.shared_with.add(recipient) if recipient == request.user:
return redirect(our_note)
our_note.shared_with.add(recipient)
except User.DoesNotExist: except User.DoesNotExist:
pass pass
return redirect(our_list) messages.success(request, "An invite has been sent if that address is registered.")
return redirect(our_note)
@login_required(login_url="/")
def set_palette(request):
if request.method == "POST":
palette = request.POST.get("palette", "")
if palette in UNLOCKED_PALETTES:
request.user.palette = palette
request.user.save(update_fields=["palette"])
return redirect("home")
@login_required(login_url="/")
def set_profile(request):
if request.method == "POST":
username = request.POST.get("username", "")
request.user.username = username
request.user.save(update_fields=["username"])
return redirect("/")
@login_required(login_url="/")
def toggle_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="dashboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
if request.headers.get("HX-Request"):
return render(request, "apps/dashboard/_partials/_applets.html", {
"applets": applet_context(request.user, "dashboard"),
"palettes": PALETTES,
"form": ItemForm(),
"recent_notes": _recent_notes(request.user),
})
return redirect("home")
@login_required(login_url="/")
@ensure_csrf_cookie
def wallet(request):
return render(request, "apps/dashboard/wallet.html", {
"wallet": request.user.wallet,
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
"applets": applet_context(request.user, "wallet"),
"page_class": "page-wallet",
})
@login_required(login_url="/")
def toggle_wallet_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="wallet"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
if request.headers.get("HX-Request"):
return render(request, "apps/wallet/_partials/_applets.html", {
"applets": applet_context(request.user, "wallet"),
"wallet": request.user.wallet,
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
})
return redirect("wallet")
@login_required(login_url="/")
def setup_intent(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
user = request.user
if not user.stripe_customer_id:
customer = stripe.Customer.create(email=user.email)
user.stripe_customer_id = customer.id
user.save(update_fields=["stripe_customer_id"])
intent = stripe.SetupIntent.create(customer=user.stripe_customer_id)
return JsonResponse({
"client_secret": intent.client_secret,
"publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
})
@login_required(login_url="/")
def save_payment_method(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
pm_id = request.POST.get("payment_method_id")
pm = stripe.PaymentMethod.retrieve(pm_id)
stripe.PaymentMethod.attach(pm_id, customer=request.user.stripe_customer_id)
PaymentMethod.objects.create(
user=request.user,
stripe_pm_id=pm_id,
last4=pm.card.last4,
brand=pm.card.brand,
)
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class GameboardConfig(AppConfig):
name = 'apps.gameboard'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,27 @@
function initGameKitTooltips() {
const portal = document.getElementById('id_tooltip_portal');
if (!portal) return;
document.querySelectorAll('#id_game_kit .token').forEach(token => {
const tooltip = token.querySelector('.token-tooltip');
if (!tooltip) return;
token.addEventListener('mouseenter', () => {
const rect = token.getBoundingClientRect();
portal.innerHTML = tooltip.innerHTML;
portal.classList.add('active');
const halfW = portal.offsetWidth / 2;
const rawLeft = rect.left + rect.width / 2;
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
portal.style.left = Math.round(clampedLeft) + 'px';
portal.style.top = Math.round(rect.top) + 'px';
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
});
token.addEventListener('mouseleave', () => {
portal.classList.remove('active');
});
});
}
document.addEventListener('DOMContentLoaded', initGameKitTooltips);

View File

View File

@@ -0,0 +1,103 @@
import lxml.html
from django.test import TestCase
from django.urls import reverse
from apps.applets.models import Applet, UserApplet
from apps.lyric.models import User
class GameboardViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
response = self.client.get("/gameboard/")
self.parsed = lxml.html.fromstring(response.content)
def test_gameboard_requires_login(self):
self.client.logout()
response = self.client.get("/gameboard/")
self.assertRedirects(
response, "/?next=/gameboard/", fetch_redirect_response=False
)
def test_gameboard_renders(self):
response = self.client.get("/gameboard/")
self.assertEqual(response.status_code, 200)
def test_gameboard_shows_my_games_applet(self):
[_] = self.parsed.cssselect("#id_applet_my_games")
def test_gameboard_shows_new_game_applet(self):
[_] = self.parsed.cssselect("#id_applet_new_game")
def test_gameboard_shows_game_kit(self):
[_] = self.parsed.cssselect("#id_game_kit")
def test_gameboard_shows_game_gear(self):
[_] = self.parsed.cssselect(".gear-btn")
def test_my_games_has_no_game_items_for_new_user(self):
game_items = self.parsed.cssselect("#id_applet_my_games .game-item")
self.assertEqual(len(game_items), 0)
def test_game_kit_has_coin_on_a_string(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_coin_on_a_string")
def test_game_kit_has_free_token(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token_0")
def test_game_kit_has_card_deck_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_card_deck")
def test_game_kit_has_dice_set_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
class ToggleGameAppletsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
self.new_game, _ = Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
self.my_games, _ = Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
self.url = reverse("toggle_game_applets")
def test_unauthenticated_user_is_redirected(self):
self.client.logout()
response = self.client.post(self.url)
self.assertRedirects(
response, f"/?next={self.url}", fetch_redirect_response=False
)
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
self.client.post(self.url, {"applets": ["new-game"]})
ua = UserApplet.objects.get(user=self.user, applet=self.my_games)
self.assertFalse(ua.visible)
def test_redirects_on_normal_post(self):
response = self.client.post(self.url, {"applets": ["new-game", "my-games"]})
self.assertRedirects(
response, reverse("gameboard"), fetch_redirect_response=False
)
def test_returns_200_on_htmx_post(self):
response = self.client.post(
self.url,
{"applets": ["new-game", "my-games"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_does_not_affect_dash_applets(self):
dash_applet, _ = Applet.objects.get_or_create(
slug="username", defaults={"name": "Username", "context": "dashboard"}
)
self.client.post(self.url, {"applets": ["new-game", "my-games"]})
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())

View File

@@ -0,0 +1,10 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.gameboard, name='gameboard'),
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
]

View File

@@ -0,0 +1,44 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from apps.applets.utils import applet_context
from apps.applets.models import Applet, UserApplet
from apps.lyric.models import Token
GAMEBOARD_APPLET_ORDER = [
"new-game",
"my-games",
"game-kit",
]
@login_required(login_url="/")
def gameboard(request):
coin = request.user.tokens.filter(token_type=Token.COIN).first()
free_tokens = list(request.user.tokens.filter(token_type=Token.FREE))
return render(
request, "apps/gameboard/gameboard.html", {
"coin": coin,
"free_tokens": free_tokens,
"applets": applet_context(request.user, "gameboard"),
"page_class": "page-gameboard",
}
)
@login_required(login_url="/")
def toggle_game_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="gameboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
if request.headers.get("HX-Request"):
return render(request, "apps/gameboard/_partials/_applets.html", {
"applets": applet_context(request.user, "gameboard"),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
})
return redirect("gameboard")

View File

@@ -1,6 +1,12 @@
from django.contrib import admin from django.contrib import admin
from .models import Token, User
from .models import LoginToken, Token, User
admin.site.register(User) class UserAdmin(admin.ModelAdmin):
list_display = ["email"]
search_fields = ["email"]
admin.site.register(User, UserAdmin)
admin.site.register(LoginToken)
admin.site.register(Token) admin.site.register(Token)

View File

@@ -1,5 +1,5 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from .models import Token, User from .models import LoginToken, User
class PasswordlessAuthenticationBackend: class PasswordlessAuthenticationBackend:
@@ -7,13 +7,13 @@ class PasswordlessAuthenticationBackend:
if uid is None: if uid is None:
return None return None
try: try:
token = Token.objects.get(uid=uid) login_token = LoginToken.objects.get(uid=uid)
except (Token.DoesNotExist, ValidationError): except (LoginToken.DoesNotExist, ValidationError):
return None return None
try: try:
return User.objects.get(email=token.email) return User.objects.get(email=login_token.email)
except User.DoesNotExist: except User.DoesNotExist:
return User.objects.create_user(email=token.email) return User.objects.create_user(email=login_token.email)
def get_user(self, user_id): def get_user(self, user_id):
try: try:

View File

View File

@@ -0,0 +1,21 @@
import os
from django.core.management.base import BaseCommand
from apps.lyric.models import User
class Command(BaseCommand):
help = "Create a superuser if none exists"
def handle(self, *args, **options):
if User.objects.filter(is_superuser=True).exists():
self.stdout.write("Superuser already exists!")
return
email = os.environ.get('DJANGO_SUPERUSER_EMAIL')
password = os.environ.get('DJANGO_SUPERUSER_PASSWORD')
if not email or not password:
self.stdout.write("Superuser credentials not set!—skipping")
return
User.objects.create_superuser(email=email, password=password)
self.stdout.write("Superuser created!")

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0 on 2026-03-02 01:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='searchable',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='user',
name='username',
field=models.CharField(blank=True, max_length=35, null=True, unique=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-03-02 04:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0002_user_searchable_user_username'),
]
operations = [
migrations.AddField(
model_name='user',
name='theme',
field=models.CharField(default='theme-default', max_length=32),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 6.0 on 2026-03-05 19:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0003_user_theme'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='theme',
),
migrations.AddField(
model_name='user',
name='palette',
field=models.CharField(default='palette-default', max_length=32),
),
]

View File

@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('lyric', '0004_remove_user_theme_user_palette'),
]
operations = [
migrations.RenameModel(
old_name="Token",
new_name="LoginToken",
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0 on 2026-03-08 18:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0005_rename_logintoken'),
]
operations = [
migrations.CreateModel(
name='Token',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_type', models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token')], max_length=8)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Wallet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('writs', models.IntegerField(default=0)),
('esteem', models.IntegerField(default=0)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='wallet', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-03-08 20:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0006_token_wallet'),
]
operations = [
migrations.AddField(
model_name='user',
name='stripe_customer_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_pm_id', models.CharField(max_length=255)),
('last4', models.CharField(max_length=4)),
('brand', models.CharField(max_length=32)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_methods', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -1,7 +1,11 @@
import uuid import uuid
from datetime import timedelta
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.db import models from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
@@ -17,13 +21,18 @@ class UserManager(BaseUserManager):
user.save(using=self._db) user.save(using=self._db)
return user return user
class Token(models.Model): class LoginToken(models.Model):
email = models.EmailField() email = models.EmailField()
uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class User(AbstractBaseUser): class User(AbstractBaseUser):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField(unique=True) email = models.EmailField(unique=True)
username = models.CharField(max_length=35, unique=True, null=True, blank=True)
searchable = models.BooleanField(default=False)
palette = models.CharField(max_length=32, default="palette-default")
stripe_customer_id = models.CharField(max_length=255, null=True, blank=True)
is_staff = models.BooleanField(default=False) is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False)
@@ -36,3 +45,87 @@ class User(AbstractBaseUser):
def has_module_perms(self, app_label): def has_module_perms(self, app_label):
return self.is_superuser return self.is_superuser
class Wallet(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="wallet")
writs = models.IntegerField(default=0)
esteem = models.IntegerField(default=0)
def tooltip_name(self):
return "Wallet"
def tooltip_description(self):
return f"{self.writs} writs · {self.esteem} esteem"
def tooltip_shoptalk(self):
return None
def tooltip_expiry(self):
return None
def tooltip_text(self):
return f"{self.tooltip_name()}: {self.tooltip_description()}"
class Token(models.Model):
COIN = "coin"
FREE = "Free"
TITHE = "tithe"
TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"),
(FREE, "Free Token"),
(TITHE, "Tithe Token"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens")
token_type = models.CharField(max_length=8, choices=TOKEN_TYPE_CHOICES)
expires_at = models.DateTimeField(null=True, blank=True)
def tooltip_name(self):
return self.get_token_type_display()
def tooltip_description(self):
if self.token_type in (self.COIN, self.FREE):
return "Admit 1 Entry"
if self.token_type == self.TITHE:
return "+ Writ bonus"
return ""
def tooltip_expiry(self):
if self.token_type == self.COIN:
return "no expiry"
if self.expires_at:
return f"Expires {self.expires_at.strftime('%Y-%m-%d')}"
return ""
def tooltip_shoptalk(self):
if self.token_type == self.COIN:
return "\u2026and another after that, and another after that\u2026"
return None
def tooltip_text(self):
text = f"{self.tooltip_name()}: {self.tooltip_description()}"
if self.tooltip_shoptalk():
text += f" ({self.tooltip_shoptalk()})"
text += f" \u2014 {self.tooltip_expiry()}"
return text
class PaymentMethod(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payment_methods")
stripe_pm_id = models.CharField(max_length=255)
last4 = models.CharField(max_length=4)
brand = models.CharField(max_length=32)
def __str__(self):
return f"{self.brand} ....{self.last4}"
@receiver(post_save, sender=User)
def create_wallet_and_tokens(sender, instance, created, **kwargs):
if not created:
return
Wallet.objects.create(user=instance, writs=144)
Token.objects.create(user=instance, token_type=Token.COIN)
Token.objects.create(
user=instance,
token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)

24
src/apps/lyric/tasks.py Normal file
View File

@@ -0,0 +1,24 @@
import requests
from celery import shared_task
from django.conf import settings
@shared_task
def send_login_email_task(email, url):
message_body = f"Use this magic link to login to your Dashboard:\n\n{url}"
# Send mail via Mailgun HTTP API
response = requests.post(
f"https://api.mailgun.net/v3/{settings.MAILGUN_DOMAIN}/messages",
auth=("api", settings.MAILGUN_API_KEY),
data={
"from": "adman@howdy.earthmanrpg.com",
"to": email,
"subject": "A magic login link to your Dashboard",
"text": message_body,
}
)
# Log any errors
if response.status_code != 200:
print(f"Mailgun API error: {response.status_code}: {response.text}")

View File

View File

@@ -0,0 +1,26 @@
from django import template
register = template.Library()
def truncate_email(email):
local, domain = email.split("@", 1)
domain_name, domain_tld = domain.rsplit(".", 1)
def truncate_segment(segment, n=2):
return segment[:n] + "" + segment[-n:]
if len(local) >= 8:
local = truncate_segment(local)
if len(domain_name) >= 6:
domain_name = truncate_segment(domain_name, 1)
return local + "@" + domain_name + "." + domain_tld
@register.filter
def display_name(user):
if user is None:
return ""
if user.username:
return user.username
return truncate_email(user.email)

View File

@@ -0,0 +1,25 @@
from django.test import TestCase
from apps.lyric.models import User
class UserAdminTest(TestCase):
def setUp(self):
self.superuser = User.objects.create_superuser(
email="admin@example.com", password="secret"
)
self.client.force_login(self.superuser)
def test_user_changelist_loads(self):
response = self.client.get("/admin/lyric/user/")
self.assertEqual(response.status_code, 200)
def test_user_changelist_displays_email(self):
response = self.client.get("/admin/lyric/user/")
self.assertContains(response, "admin@example.com")
def test_user_changelist_search_by_email(self):
User.objects.create_superuser(email="other@example.com", password="x")
response = self.client.get("/admin/lyric/user/?q=admin")
self.assertContains(response, "admin@example.com")
self.assertNotContains(response, "other@example.com")

View File

@@ -3,39 +3,39 @@ from django.http import HttpRequest
from django.test import TestCase from django.test import TestCase
from apps.lyric.authentication import PasswordlessAuthenticationBackend from apps.lyric.authentication import PasswordlessAuthenticationBackend
from apps.lyric.models import Token, User from apps.lyric.models import LoginToken, User
class AuthenticateTest(TestCase): class AuthenticateTest(TestCase):
def test_returns_None_if_token_uuid_not_found(self): def test_returns_None_if_login_token_uuid_not_found(self):
uid = uuid.uuid4() uid = uuid.uuid4()
result = PasswordlessAuthenticationBackend().authenticate( result = PasswordlessAuthenticationBackend().authenticate(
HttpRequest(), uid HttpRequest(), uid
) )
self.assertIsNone(result) self.assertIsNone(result)
def test_returns_new_user_with_correct_email_if_token_exists(self): def test_returns_new_user_with_correct_email_if_login_token_exists(self):
email = "discoman@example.com" email = "discoman@example.com"
token = Token.objects.create(email=email) login_token = LoginToken.objects.create(email=email)
user = PasswordlessAuthenticationBackend().authenticate( user = PasswordlessAuthenticationBackend().authenticate(
HttpRequest(), token.uid HttpRequest(), login_token.uid
) )
new_user = User.objects.get(email=email) new_user = User.objects.get(email=email)
self.assertEqual(user, new_user) self.assertEqual(user, new_user)
def test_returns_existing_user_with_correct_email_if_token_exists(self): def test_returns_existing_user_with_correct_email_if_login_token_exists(self):
email = "discoman@example.com" email = "discoman@example.com"
existing_user = User.objects.create(email=email) existing_user = User.objects.create(email=email)
token = Token.objects.create(email=email) login_token = LoginToken.objects.create(email=email)
user = PasswordlessAuthenticationBackend().authenticate( user = PasswordlessAuthenticationBackend().authenticate(
HttpRequest(), token.uid HttpRequest(), login_token.uid
) )
self.assertEqual(user, existing_user) self.assertEqual(user, existing_user)
def test_can_retrieve_token_by_uuid(self): def test_can_retrieve_login_token_by_uuid(self):
token = Token.objects.create(email="a@b.cde") login_token = LoginToken.objects.create(email="a@b.cde")
fetched = Token.objects.get(pk=token.uid) fetched = LoginToken.objects.get(pk=login_token.uid)
self.assertEqual(fetched, token) self.assertEqual(fetched, login_token)
class GetUserTest(TestCase): class GetUserTest(TestCase):
def test_gets_user_by_uuid(self): def test_gets_user_by_uuid(self):

View File

@@ -0,0 +1,34 @@
import os
from django.core.management import call_command
from django.test import TestCase
from unittest.mock import patch
# from apps.lyric.management.commands.ensure_superuser import EnsureSuperuserCommand
from apps.lyric.models import User
FAKE_ENV = {
'DJANGO_SUPERUSER_EMAIL': 'admin@example.com',
'DJANGO_SUPERUSER_PASSWORD': 'secret',
}
class EnsureSuperuserCommandTest(TestCase):
def test_creates_superuser_if_none_exists(self):
with patch.dict('os.environ', FAKE_ENV):
call_command('ensure_superuser')
self.assertEqual(User.objects.filter(is_superuser=True).count(), 1)
def test_does_not_create_duplicate_if_superuser_exists(self):
User.objects.create_superuser(email="admin@example.com", password="secret")
with patch.dict('os.environ', FAKE_ENV):
call_command('ensure_superuser')
self.assertEqual(User.objects.filter(is_superuser=True).count(), 1)
def test_skips_creation_if_credentials_not_set(self):
with patch.dict("os.environ", {}):
os.environ.pop("DJANGO_SUPERUSER_EMAIL", None)
os.environ.pop("DJANGO_SUPERUSER_PASSWORD", None)
call_command("ensure_superuser")
self.assertEqual(User.objects.filter(is_superuser=True).count(), 0)

View File

@@ -1,8 +1,9 @@
import uuid import uuid
from django.contrib import auth from django.contrib import auth
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from apps.lyric.models import Token, User from apps.lyric.models import LoginToken, Token, User, Wallet
class UserModelTest(TestCase): class UserModelTest(TestCase):
@@ -18,12 +19,22 @@ class UserModelTest(TestCase):
user = User(id="123") user = User(id="123")
self.assertEqual(user.pk, "123") self.assertEqual(user.pk, "123")
class TokenModelTest(TestCase): def test_user_can_have_a_username(self):
user = User.objects.create(email="a@b.cde")
user.username = "stardust"
user.save()
self.assertEqual(User.objects.get(pk=user.pk).username, "stardust")
def test_searchable_defaults_to_false(self):
user = User.objects.create(email="a@b.cde")
self.assertFalse(user.searchable)
class LoginTokenModelTest(TestCase):
def test_links_user_with_autogen_uid(self): def test_links_user_with_autogen_uid(self):
token1 = Token.objects.create(email="a@b.cde") login_token1 = LoginToken.objects.create(email="a@b.cde")
token2 = Token.objects.create(email="v@w.xyz") login_token2 = LoginToken.objects.create(email="v@w.xyz")
self.assertNotEqual(token1.pk, token2.pk) self.assertNotEqual(login_token1.pk, login_token2.pk)
self.assertIsInstance(token1.pk, uuid.UUID) self.assertIsInstance(login_token1.pk, uuid.UUID)
class UserManagerTest(TestCase): class UserManagerTest(TestCase):
def test_create_superuser_sets_is_staff_and_is_superuser(self): def test_create_superuser_sets_is_staff_and_is_superuser(self):
@@ -40,3 +51,48 @@ class UserManagerTest(TestCase):
password="correct-password", password="correct-password",
) )
self.assertTrue(user.check_password("correct-password")) self.assertTrue(user.check_password("correct-password"))
class UserPaletteTest(TestCase):
def test_palette_field_defaults_to_palette_default(self):
user = User.objects.create(email="a@b.cde")
self.assertEqual(user.palette, "palette-default")
class WalletCreationTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
def test_wallet_is_created_for_new_user(self):
self.assertTrue(Wallet.objects.filter(user=self.user).exists())
def test_new_wallet_has_144_writs(self):
wallet = Wallet.objects.get(user = self.user)
self.assertEqual(wallet.writs, 144)
def test_new_wallet_has_0_esteem(self):
wallet = Wallet.objects.get(user=self.user)
self.assertEqual(wallet.esteem, 0)
class TokenCreationTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
def test_coin_on_a_string_created_for_new_user(self):
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.COIN).exists()
)
def test_free_token_created_for_new_user(self):
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.FREE).exists()
)
def test_coin_on_a_string_has_no_expiry(self):
coin = Token.objects.get(user=self.user, token_type=Token.COIN)
self.assertIsNone(coin.expires_at)
def test_free_token_has_expiry_within_7_days(self):
free = Token.objects.get(user=self.user, token_type=Token.FREE)
self.assertIsNotNone(free.expires_at)
delta = free.expires_at - timezone.now()
self.assertLessEqual(delta.days, 7)
self.assertGreater(delta.total_seconds(), 0)

View File

@@ -2,32 +2,28 @@ from django.contrib import auth
from django.test import TestCase from django.test import TestCase
from unittest import mock from unittest import mock
from apps.lyric.models import Token from apps.lyric.models import LoginToken
@mock.patch("apps.lyric.views.send_login_email_task.delay")
class SendLoginEmailViewTest(TestCase): class SendLoginEmailViewTest(TestCase):
def test_redirects_to_home_page(self): def test_redirects_to_home_page(self, mock_delay):
response = self.client.post( response = self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
self.assertRedirects(response, "/") self.assertRedirects(response, "/")
@mock.patch("apps.lyric.views.requests.post") def test_sends_mail_to_address_from_post(self, mock_delay):
def test_sends_mail_to_address_from_post(self, mock_post):
self.client.post( self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
self.assertEqual(mock_post.called, True) self.assertEqual(mock_delay.called, True)
data = mock_post.call_args.kwargs["data"] self.assertEqual(mock_delay.call_args.args[0], "discoman@example.com")
self.assertEqual(data["subject"], "A magic login link to your Dashboard")
self.assertEqual(data["from"], "adman@howdy.earthmanrpg.com")
self.assertEqual(data["to"], "discoman@example.com")
def test_adds_success_message(self): def test_adds_success_message(self, mock_delay):
response = self.client.post( response = self.client.post(
"/apps/lyric/send_login_email", "/lyric/send_login_email",
data={"email": "discoman@example.com"}, data={"email": "discoman@example.com"},
follow=True follow=True
) )
@@ -39,43 +35,40 @@ class SendLoginEmailViewTest(TestCase):
) )
self.assertEqual(message.tags, "success") self.assertEqual(message.tags, "success")
def test_creates_token_associated_with_email(self): def test_creates_login_token_associated_with_email(self, mock_delay):
self.client.post( self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
token = Token.objects.get() login_token = LoginToken.objects.get()
self.assertEqual(token.email, "discoman@example.com") self.assertEqual(login_token.email, "discoman@example.com")
def test_sends_link_to_login_using_login_token_uid(self, mock_delay):
@mock.patch("apps.lyric.views.requests.post")
def test_sends_link_to_login_using_token_uid(self, mock_post):
self.client.post( self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
token = Token.objects.get() login_token = LoginToken.objects.get()
expected_url = f"http://testserver/apps/lyric/login?token={token.uid}" expected_url = f"http://testserver/lyric/login?token={login_token.uid}"
data = mock_post.call_args.kwargs["data"] self.assertEqual(mock_delay.call_args.args[1], expected_url)
self.assertIn(expected_url, data["text"])
class LoginViewTest(TestCase): class LoginViewTest(TestCase):
def test_redirects_to_home_page(self): def test_redirects_to_home_page(self):
response = self.client.get("/apps/lyric/login?token=abc123") response = self.client.get("/lyric/login?token=abc123")
self.assertRedirects(response, "/") self.assertRedirects(response, "/")
def test_logs_in_if_given_valid_token(self): def test_logs_in_if_given_valid_login_token(self):
anon_user = auth.get_user(self.client) anon_user = auth.get_user(self.client)
self.assertEqual(anon_user.is_authenticated, False) self.assertEqual(anon_user.is_authenticated, False)
token = Token.objects.create(email="discoman@example.com") login_token = LoginToken.objects.create(email="discoman@example.com")
self.client.get(f"/apps/lyric/login?token={token.uid}", follow=True) self.client.get(f"/lyric/login?token={login_token.uid}", follow=True)
user = auth.get_user(self.client) user = auth.get_user(self.client)
self.assertEqual(user.is_authenticated, True) self.assertEqual(user.is_authenticated, True)
self.assertEqual(user.email, "discoman@example.com") self.assertEqual(user.email, "discoman@example.com")
def test_shows_login_error_if_token_invalid(self): def test_shows_login_error_if_login_token_invalid(self):
response = self.client.get("/apps/lyric/login?token=invalid-token", follow=True) response = self.client.get("/lyric/login?token=invalid-token", follow=True)
user = auth.get_user(self.client) user = auth.get_user(self.client)
self.assertEqual(user.is_authenticated, False) self.assertEqual(user.is_authenticated, False)
message = list(response.context["messages"])[0] message = list(response.context["messages"])[0]
@@ -87,7 +80,7 @@ class LoginViewTest(TestCase):
@mock.patch("apps.lyric.views.auth") @mock.patch("apps.lyric.views.auth")
def test_calls_authenticate_with_uid_from_get_request(self, mock_auth): def test_calls_authenticate_with_uid_from_get_request(self, mock_auth):
self.client.get("/apps/lyric/login?token=abc123") self.client.get("/lyric/login?token=abc123")
self.assertEqual( self.assertEqual(
mock_auth.authenticate.call_args, mock_auth.authenticate.call_args,
mock.call(uid="abc123") mock.call(uid="abc123")

View File

@@ -0,0 +1,16 @@
from django.test import SimpleTestCase
from unittest import mock
from apps.lyric.tasks import send_login_email_task
class SendLoginEmailTaskTest(SimpleTestCase):
@mock.patch("apps.lyric.tasks.requests.post")
def test_sends_mail_via_mailgun(self, mock_post):
send_login_email_task("discoman@example.com", "http://example.com/login?token=abc123")
self.assertEqual(mock_post.called, True)
data = mock_post.call_args.kwargs["data"]
self.assertEqual(data["subject"], "A magic login link to your Dashboard")
self.assertEqual(data["from"], "adman@howdy.earthmanrpg.com")
self.assertEqual(data["to"], "discoman@example.com")
self.assertIn("http://example.com/login?token=abc123", data["text"])

View File

@@ -0,0 +1,36 @@
from django.test import SimpleTestCase
from unittest.mock import Mock
from apps.lyric.templatetags.lyric_extras import display_name, truncate_email
class TruncateEmailTest(SimpleTestCase):
def test_truncates_neither_short_local_nor_short_domain(self):
self.assertEqual(truncate_email("abc@d.e"), "abc@d.e")
def test_truncates_only_long_local_not_short_domain(self):
self.assertEqual(truncate_email("sesquipedalian@abc.de"), "se…an@abc.de")
def test_truncates_not_short_local_only_long_domain(self):
self.assertEqual(truncate_email("abc@longexample.com"), "abc@l…e.com")
def test_truncates_both_long_local_and_long_domain(self):
self.assertEqual(truncate_email("onomatopoeia@earthmanrpg.com"), "on…ia@e…g.com")
def test_boundary_case_longish_segments_no_truncate(self):
self.assertEqual(truncate_email("abcdefg@gmail.com"), "abcdefg@gmail.com")
def test_boundary_case_exact_segments_do_truncate(self):
self.assertEqual(truncate_email("abcdefgh@icloud.com"), "ab…gh@i…d.com")
class DisplayNameFilterTest(SimpleTestCase):
def test_returns_empty_string_for_none_user(self):
self.assertEqual(display_name(None), "")
def test_returns_truncated_email_when_no_username(self):
user = Mock(username="", email="sesquipedalian@abc.de")
self.assertEqual(display_name(user), "se…an@abc.de")
def test_returns_username_when_set(self):
user = Mock(username="earthman", email="sesquipedalian@abc.de")
self.assertEqual(display_name(user), "earthman")

View File

@@ -0,0 +1,43 @@
from django.test import SimpleTestCase
from unittest.mock import MagicMock
from apps.lyric.models import Token
class CoinTooltipTest(SimpleTestCase):
def setUp(self):
self.coin = Token ()
self.coin.token_type = Token.COIN
self.coin.expires_at = None
def test_tooltip_contains_name(self):
self.assertIn("Coin-on-a-String", self.coin.tooltip_text())
def test_tooltip_contains_entry(self):
self.assertIn("Admit 1 Entry", self.coin.tooltip_text())
def test_tooltip_contains_reuse_description(self):
self.assertIn("and another after that", self.coin.tooltip_text())
def test_tooltip_contains_no_expiry(self):
self.assertIn("no expiry", self.coin.tooltip_text())
class FreeTokenTooltipTest(SimpleTestCase):
def setUp(self):
self.token = Token()
self.token.token_type = Token.FREE
self.token.expires_at = MagicMock()
self.token.expires_at.strftime = lambda fmt: "2026-03-15"
def test_tooltip_contains_name(self):
self.assertIn("Free Token", self.token.tooltip_text())
def test_tooltip_contains_entry(self):
self.assertIn("Admit 1 Entry", self.token.tooltip_text())
def test_tooltip_contains_expires(self):
self.assertIn("Expires", self.token.tooltip_text())
def test_tooltip_contains_expiry_date(self):
self.assertIn("2026-03-15", self.token.tooltip_text())

View File

@@ -1,37 +1,24 @@
import requests
from django.contrib import auth, messages from django.contrib import auth, messages
from django.conf import settings
# from django.core.mail import send_mail
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from .models import Token
from .models import LoginToken
from .tasks import send_login_email_task
def send_login_email(request): def send_login_email(request):
email = request.POST["email"] email = request.POST["email"]
token = Token.objects.create(email=email) login_token = LoginToken.objects.create(email=email)
url = request.build_absolute_uri( url = request.build_absolute_uri(
reverse("login") + "?token=" + str(token.uid), reverse("login") + "?token=" + str(login_token.uid),
) )
message_body = f"Use this magic link to login to your Dashboard:\n\n{url}"
# Send mail via Mailgun HTTP API
response = requests.post(
f"https://api.mailgun.net/v3/{settings.MAILGUN_DOMAIN}/messages",
auth=("api", settings.MAILGUN_API_KEY),
data={
"from": "adman@howdy.earthmanrpg.com",
"to": email,
"subject": "A magic login link to your Dashboard",
"text": message_body,
},
)
# Log any errors
if response.status_code != 200:
print(f"Mailgun API error: {response.status_code}: {response.text}")
send_login_email_task.delay(email, url)
messages.success( messages.success(
request, request,
"Check your email!—there you'll find a magic login link. But hurry… it's only temporary!", "Check your email!—there you'll find a magic login link. But hurry… it's only temporary!",
) )
return redirect("/") return redirect("/")
def login(request): def login(request):

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)

10
src/core/celery.py Normal file
View File

@@ -0,0 +1,10 @@
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
app = Celery('core')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

View File

@@ -0,0 +1,4 @@
def user_palette(request):
if request.user.is_authenticated:
return {"user_palette": request.user.palette}
return {"user_palette": "palette-default"}

View File

@@ -11,8 +11,11 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
from pathlib import Path from pathlib import Path
import os
import dj_database_url import dj_database_url
import os
import sys
if 'test' in sys.argv:
COMPRESS_ENABLED = False
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -34,11 +37,13 @@ if 'DJANGO_DEBUG_FALSE' in os.environ:
SECURE_HSTS_SECONDS = 60 SECURE_HSTS_SECONDS = 60
SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True SECURE_HSTS_PRELOAD = True
COMPRESS_OFFLINE = True
else: else:
DEBUG = True DEBUG = True
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-&9b_h=qpjy=sshhnsyg98&jp7(t6*v78__y%h2l$b#_@6z$-9r' SECRET_KEY = 'django-insecure-&9b_h=qpjy=sshhnsyg98&jp7(t6*v78__y%h2l$b#_@6z$-9r'
ALLOWED_HOSTS = [] ALLOWED_HOSTS = ['*']
COMPRESS_CACHE_BACKEND = 'dummy'
# Application definition # Application definition
@@ -51,11 +56,18 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
# Custom apps # Board apps
'apps.dashboard', 'apps.dashboard',
'apps.gameboard',
# Gamer apps
'apps.lyric', 'apps.lyric',
# Custom apps
'apps.api',
'apps.applets',
'functional_tests', 'functional_tests',
# Depend apps # Depend apps
'compressor',
'rest_framework',
] ]
# if 'DJANGO_DEBUG_FALSE' not in os.environ: # if 'DJANGO_DEBUG_FALSE' not in os.environ:
@@ -70,6 +82,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_htmx.middleware.HtmxMiddleware',
] ]
ROOT_URLCONF = 'core.urls' ROOT_URLCONF = 'core.urls'
@@ -84,6 +97,7 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'core.context_processors.user_palette',
], ],
}, },
}, },
@@ -107,6 +121,17 @@ else:
} }
} }
# Celery & Redis
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
REDIS_URL = os.environ.get('REDIS_URL')
if REDIS_URL:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': REDIS_URL,
}
}
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -154,6 +179,14 @@ STATIC_ROOT = BASE_DIR / 'static'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'static_src', BASE_DIR / 'static_src',
] ]
STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'compressor.finders.CompressorFinder',
]
COMPRESS_PRECOMPILERS = [
('text/x-scss', 'django_libsass.SassCompiler'),
]
LOGGING = { LOGGING = {
"version": 1, "version": 1,
@@ -168,4 +201,8 @@ LOGGING = {
# Mailgun API settings (for HTTP API instead of SMTP) # Mailgun API settings (for HTTP API instead of SMTP)
MAILGUN_API_KEY = os.environ.get("MAILGUN_API_KEY") MAILGUN_API_KEY = os.environ.get("MAILGUN_API_KEY")
MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain
# Stripe payment settings
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")

View File

@@ -1,13 +1,17 @@
from django.contrib import admin from django.contrib import admin
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import include, path from django.urls import include, path
from apps.dashboard import views as dash_views from apps.dashboard import views as dash_views
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('', dash_views.home_page, name='home'), path('', dash_views.home_page, name='home'),
path('apps/dashboard/', include('apps.dashboard.urls')), path('api/', include('apps.api.urls')),
path('apps/lyric/', include('apps.lyric.urls')), path('dashboard/', include('apps.dashboard.urls')),
path('lyric/', include('apps.lyric.urls')),
path('gameboard/', include('apps.gameboard.urls')),
] ]
# Please remove the following urlpattern # Please remove the following urlpattern

View File

@@ -8,10 +8,10 @@ from pathlib import Path
from selenium import webdriver from selenium import webdriver
from selenium.common.exceptions import WebDriverException from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .container_commands import create_session_on_server, reset_database from .container_commands import create_session_on_server, reset_database
from .management.commands.create_session import create_pre_authenticated_session from .management.commands.create_session import create_pre_authenticated_session
from apps.applets.models import Applet
@@ -37,13 +37,17 @@ class FunctionalTest(StaticLiveServerTestCase):
# Helper methods # Helper methods
def setUp(self): def setUp(self):
options = webdriver.FirefoxOptions() options = webdriver.FirefoxOptions()
if os.environ.get("HEADLESS"): headless = os.environ.get("HEADLESS")
if headless:
options.add_argument("--headless") options.add_argument("--headless")
self.browser = webdriver.Firefox(options=options) self.browser = webdriver.Firefox(options=options)
if headless:
self.browser.set_window_size(1366, 900)
self.test_server = os.environ.get("TEST_SERVER") self.test_server = os.environ.get("TEST_SERVER")
if self.test_server: if self.test_server:
self.live_server_url = 'http://' + self.test_server self.live_server_url = 'http://' + self.test_server
reset_database(self.test_server) reset_database(self.test_server)
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
def tearDown(self): def tearDown(self):
if self._test_has_failed(): if self._test_has_failed():
@@ -65,7 +69,7 @@ class FunctionalTest(StaticLiveServerTestCase):
def dump_html(self): def dump_html(self):
path = SCREEN_DUMP_LOCATION / self._get_filename("html") path = SCREEN_DUMP_LOCATION / self._get_filename("html")
print("dumping page html to", path) print("dumping page html to", path)
path.write_text(self.browser.page_source) path.write_text(self.browser.page_source, encoding="utf-8")
def _get_filename(self, extension): def _get_filename(self, extension):
timestamp = datetime.now().isoformat().replace(":", ".") timestamp = datetime.now().isoformat().replace(":", ".")
@@ -77,6 +81,16 @@ class FunctionalTest(StaticLiveServerTestCase):
@wait @wait
def wait_for(self, fn): def wait_for(self, fn):
return fn() return fn()
def wait_for_slow(self, fn, timeout=30):
start_time = time.time()
while True:
try:
return fn()
except (AssertionError, WebDriverException) as e:
if time.time() - start_time > timeout:
raise e
time.sleep(0.5)
def create_pre_authenticated_session(self, email): def create_pre_authenticated_session(self, email):
if self.test_server: if self.test_server:

View File

@@ -1,17 +0,0 @@
from selenium.webdriver.common.by import By
class MyListsPage:
def __init__(self, test):
self.test = test
def go_to_my_lists_page(self, email):
self.test.browser.get(self.test.live_server_url)
self.test.browser.find_element(By.LINK_TEXT, "My lists").click()
self.test.wait_for(
lambda: self.test.assertIn(
email,
self.test.browser.find_element(By.TAG_NAME, "h2").text,
)
)
return self

View File

@@ -0,0 +1,22 @@
from selenium.webdriver.common.by import By
from apps.lyric.models import User
class MyNotesPage:
def __init__(self, test):
self.test = test
def go_to_my_notes_page(self, email):
self.test.browser.get(self.test.live_server_url)
user = User.objects.get(email=email)
self.test.browser.get(
self.test.live_server_url + f'/dashboard/users/{user.id}/'
)
self.test.wait_for(
lambda: self.test.assertIn(
email,
self.test.browser.find_element(By.CSS_SELECTOR, ".navbar-identity").text.lower(),
)
)
return self

View File

@@ -4,42 +4,42 @@ from selenium.webdriver.common.keys import Keys
from .base import wait from .base import wait
class ListPage: class NotePage:
def __init__(self, test): def __init__(self, test):
self.test = test self.test = test
def get_table_rows(self): def get_table_rows(self):
return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr") return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_note_table tr")
@wait @wait
def wait_for_row_in_list_table(self, item_text, item_number): def wait_for_row_in_note_table(self, item_text, item_number):
expected_row_text = f"{item_number}. {item_text}" expected_row_text = f"{item_number}. {item_text}"
rows = self.get_table_rows() rows = self.get_table_rows()
self.test.assertIn(expected_row_text, [row.text for row in rows]) self.test.assertIn(expected_row_text, [row.text for row in rows])
def get_item_input_box(self): def get_item_input_box(self):
return self.test.browser.find_element(By.ID, "id_text") return self.test.browser.find_element(By.ID, "id_text")
def add_list_item(self, item_text): def add_note_item(self, item_text):
new_item_no = len(self.get_table_rows()) + 1 new_item_no = len(self.get_table_rows()) + 1
self.get_item_input_box().send_keys(item_text) self.get_item_input_box().send_keys(item_text)
self.get_item_input_box().send_keys(Keys.ENTER) self.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table(item_text, new_item_no) self.wait_for_row_in_note_table(item_text, new_item_no)
return self return self
def get_share_box(self): def get_share_box(self):
return self.test.browser.find_element( return self.test.browser.find_element(
By.CSS_SELECTOR, By.CSS_SELECTOR,
'input[name="recipient"]', 'input[name="recipient"]',
) )
def get_shared_with_list(self): def get_shared_with_list(self):
return self.test.browser.find_elements( return self.test.browser.find_elements(
By.CSS_SELECTOR, By.CSS_SELECTOR,
".list-recipient" ".note-recipient"
) )
def share_list_with(self, email): def share_note_with(self, email):
self.get_share_box().send_keys(email) self.get_share_box().send_keys(email)
self.get_share_box().send_keys(Keys.ENTER) self.get_share_box().send_keys(Keys.ENTER)
self.test.wait_for( self.test.wait_for(
@@ -48,5 +48,5 @@ class ListPage:
) )
) )
def get_list_owner(self): def get_note_owner(self):
return self.test.browser.find_element(By.ID, "id_list_owner").text return self.test.browser.find_element(By.ID, "id_note_owner").text

View File

@@ -0,0 +1,10 @@
from selenium.webdriver.common.by import By
from .base import FunctionalTest
class SiteThemeTest(FunctionalTest):
def test_page_renders_with_earthman_palette(self):
self.browser.get(self.live_server_url)
body = self.browser.find_element(By.TAG_NAME, "body")
self.assertIn("palette-default", body.get_attribute("class"))

View File

@@ -0,0 +1,160 @@
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
from apps.applets.models import Applet
class DashboardMaintenanceTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
def test_user_without_username_can_claim_unclaimed_username(self):
# 1. Create a pre-authenticated session for discoman@example.com
self.create_pre_authenticated_session("discoman@example.com")
# 2. Navigate to self.live_server_url + "/"
self.browser.get(self.live_server_url)
# 3. Find the username applet on the page; look for a <section> or <div> with id="id_username_applet"
self.browser.find_element(By.ID, "id_applet_username")
# 5. Find the username input field inside the applet & type a username
username_input = self.browser.find_element(By.CSS_SELECTOR, "#id_new_username")
# 4. Assert it shows the current display name (truncated email: di…an@e…e.com) NOPE the username value itself now
self.assertEqual("", username_input.get_attribute("value"))
# 6. Type a username, e.g., discoman
username_input.send_keys("discoman")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_new_username:valid")
)
# 7. Submit the form (click a btn or press Enter)
username_input.send_keys(Keys.ENTER)
# 8. Without a page reload, wait for the navbar to update; user wait_for() to check that the navbar text now contains "discoman"
self.wait_for(
lambda: self.assertIn(
"discoman",
self.browser.find_element(By.CLASS_NAME, "navbar-text").text
)
)
# 9. Also assert the applet input now shows "discoman" as its value
self.wait_for(
lambda: self.assertEqual(
"discoman",
self.browser.find_element(By.CSS_SELECTOR, "#id_new_username").get_attribute("value")
)
)
def test_user_can_toggle_applet_visibility_via_gear_menu(self):
# 1. Auth as discoman@example.com, navigate home
self.create_pre_authenticated_session("discoman@example.com")
self.browser.get(self.live_server_url)
# 2. Assert both applets present on page (id_applet_username, id_applet_palette)
self.browser.find_element(By.ID, "id_applet_username")
self.browser.find_element(By.ID, "id_applet_palette")
# 3. Click el w. id="id_dash_gear"
dash_gear = self.browser.find_element(By.CSS_SELECTOR, ".gear-btn")
dash_gear.click()
# 4. A menu appears; wait_for el w. id="id_dash_applet_menu"
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
# 5. Find two checkboxes in menu, name="username" & name="palette"; assert both .is_selected()
menu = self.browser.find_element(By.ID, "id_dash_applet_menu")
username_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="username"]')
palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]')
self.assertTrue(username_cb.is_selected())
self.assertTrue(palette_cb.is_selected())
# 6. Click palette box to uncheck it
palette_cb.click()
self.assertFalse(palette_cb.is_selected())
self.browser.execute_script("window.__no_reload_marker = true")
# 7. Submit the menu form via [type="submit"] btn inside menu
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
# 8. wait_for palette applet to be gone
self.wait_for(
lambda: self.assertRaises(
NoSuchElementException,
self.browser.find_element,
By.ID, "id_applet_palette"
)
)
# 9. assert id_applet_username remains
self.browser.find_element(By.ID, "id_applet_username")
# 10. Click gear again, find menu, find palette checkbox; assert now NOT selected
dash_gear.click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
menu = self.browser.find_element(By.ID, "id_dash_applet_menu")
palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]')
self.assertFalse(palette_cb.is_selected())
# 11. Click it to re-check box; submit
palette_cb.click()
self.assertTrue(palette_cb.is_selected())
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
# 12. wait_for id_applet_palette to reappear
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_palette")
)
)
self.assertTrue(self.browser.execute_script("return window.__no_reload_marker === true"))
class AppletMenuDismissTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
self.create_pre_authenticated_session("discoman@example.com")
self.browser.get(self.live_server_url)
def _open_menu(self):
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
def test_gear_click_toggles_menu_closed(self):
self._open_menu()
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
def test_nvm_btn_closes_menu(self):
self._open_menu()
self.browser.find_element(By.ID, "id_applet_menu_cancel").click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)
def test_click_outside_closes_menu(self):
self._open_menu()
self.browser.find_element(By.TAG_NAME, "h2").click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
)
)

View File

@@ -0,0 +1,158 @@
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.applets.models import Applet
class GameboardNavigationTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
def test_footer_links_to_gameboard(self):
# 1. Log in, nav to dashboard
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url)
# 2. Assert footer nav present w. dash- & gameboard tabs
self.browser.find_element(By.ID, "id_footer_nav")
self.browser.find_element(By.CSS_SELECTOR, '#id_footer_nav a[href="/"]')
self.browser.find_element(By.CSS_SELECTOR, '#id_footer_nav a[href="/gameboard/"]')
# 3. Click the gameboard tab
self.browser.find_element(
By.CSS_SELECTOR, '#id_footer_nav a[href="/gameboard/"]'
).click()
# 4. Assert user landed on gameboard
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/gameboard/$")
)
def test_gameboard_shows_game_applets(self):
# 1. Log in, nav directly to gameboard
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
# 2. Assert My Games applet present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_games")
)
# 3. Assert no games listed yet for new user
my_games = self.browser.find_element(By.ID, "id_applet_my_games")
game_items = my_games.find_elements(By.CSS_SELECTOR, ".game-item")
self.assertEqual(len(game_items), 0)
# 4. Assert New Game applet present
self.browser.find_element(By.ID, "id_applet_new_game")
def test_game_kit_panel_shows_token_inventory(self):
# 1. Log in, nav to gameboard
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
# 2. Assert game kit applet & gear btn present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_game_kit")
)
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn")
# 3. Assert Coin-on-a-String present in kit
coin = self.browser.find_element(By.ID, "id_kit_coin_on_a_string")
self.browser.execute_script("arguments[0].scrollIntoView({block: 'center'});", coin)
# 6. Hover over it; assert tooltip shows name, entry text & reuse description
ActionChains(self.browser).move_to_element(coin).perform()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed()
)
)
coin_tooltip = self.browser.find_element(By.ID, "id_tooltip_portal").text
self.assertIn("Coin-on-a-String", coin_tooltip)
self.assertIn("Admit 1 Entry", coin_tooltip)
self.assertIn("and another after that", coin_tooltip)
# 7. Assert 1× Free Token (complimentary) present in kit
free_token = self.browser.find_element(By.ID, "id_kit_free_token_0")
# 8. Hover over it; assert tooltip shows name, entry text & expiry date
ActionChains(self.browser).move_to_element(free_token).perform()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed()
)
)
free_tooltip = self.browser.find_element(By.ID, "id_tooltip_portal").text
self.assertIn("Free Token", free_tooltip)
self.assertIn("Admit 1 Entry", free_tooltip)
self.assertIn("Expires", free_tooltip)
# 9. Assert card deck & dice set placeholder present
self.browser.find_element(By.ID, "id_kit_card_deck")
self.browser.find_element(By.ID, "id_kit_dice_set")
class GameboardAppletMenuTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
self.create_pre_authenticated_session("gamer@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
def test_user_can_toggle_applet_visibility_via_gear_menu(self):
# 1. Assert both applets present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_games")
)
self.browser.find_element(By.ID, "id_applet_new_game")
# 2. Click gear; wait for menu
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
# 3. Find checkboxes; assert both checked
menu = self.browser.find_element(By.ID, "id_game_applet_menu")
my_games_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="my-games"]')
new_game_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="new-game"]')
self.assertTrue(my_games_cb.is_selected())
self.assertTrue(new_game_cb.is_selected())
# 4. Uncheck my-games; plant no-reload marker; submit
my_games_cb.click()
self.assertFalse(my_games_cb.is_selected())
self.browser.execute_script("window.__no_reload_marker = true")
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
# 5. Wait for menu to close; assert my-games gone, new game remains
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
self.wait_for(
lambda: self.assertRaises(
NoSuchElementException,
self.browser.find_element,
By.ID, "id_applet_my_games",
)
)
self.browser.find_element(By.ID, "id_applet_new_game")
# 6. Re-check my-games; assert it reappears
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
menu = self.browser.find_element(By.ID, "id_game_applet_menu")
my_games_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="my-games"]')
self.assertFalse(my_games_cb.is_selected())
my_games_cb.click()
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_my_games")
)
)
# 7. Assert no full page reload occurred
self.assertTrue(self.browser.execute_script("return window.__no_reload_marker === true"))

View File

@@ -1,27 +1,25 @@
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest from .base import FunctionalTest
from .list_page import ListPage from .note_page import NotePage
class LayoutAndStylingTest(FunctionalTest): class LayoutAndStylingTest(FunctionalTest):
def test_layout_and_styling(self): def test_layout_and_styling(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self) note_page = NotePage(self)
self.browser.set_window_size(1024, 768) self.browser.set_window_size(1024, 768)
# print("Viewport width:", self.browser.execute_script("return window.innerWidth")) # print("Viewport width:", self.browser.execute_script("return window.innerWidth"))
inputbox = list_page.get_item_input_box() inputbox = note_page.get_item_input_box()
self.assertAlmostEqual( self.assertAlmostEqual(
inputbox.location['x'] + inputbox.size['width'] / 2, inputbox.location['x'] + inputbox.size['width'] / 2,
512, 512,
delta=10, delta=10,
) )
list_page.add_list_item("testing") note_page.add_note_item("testing")
inputbox = list_page.get_item_input_box() inputbox = note_page.get_item_input_box()
self.assertAlmostEqual( self.assertAlmostEqual(
inputbox.location['x'] + inputbox.size['width'] / 2, inputbox.location['x'] + inputbox.size['width'] / 2,
512, 512,

View File

@@ -1,18 +1,22 @@
import re import re
from unittest.mock import patch from unittest.mock import patch
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest from .base import FunctionalTest
from apps.lyric.tasks import send_login_email_task
TEST_EMAIL = "discoman@example.com" TEST_EMAIL = "disco@test.io"
SUBJECT = "A magic login link to your Dashboard" SUBJECT = "A magic login link to your Dashboard"
class LoginTest(FunctionalTest): class LoginTest(FunctionalTest):
@patch('apps.lyric.views.requests.post') @patch('apps.lyric.tasks.requests.post')
def test_login_using_magic_link(self, mock_post): @patch('apps.lyric.views.send_login_email_task.delay',
side_effect=send_login_email_task)
def test_login_using_magic_link(self, mock_delay, mock_post):
# Mock successful Mailgun API response # Mock successful Mailgun API response
mock_post.return_value.status_code = 200 mock_post.return_value.status_code = 200

View File

@@ -1,45 +0,0 @@
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .list_page import ListPage
from .my_lists_page import MyListsPage
class MyListsTest(FunctionalTest):
def test_logged_in_users_lists_are_saved_as_my_lists(self):
self.create_pre_authenticated_session("discoman@example.com")
self.browser.get(self.live_server_url)
list_page = ListPage(self)
list_page.add_list_item("Reticulate splines")
list_page.add_list_item("Regurgitate spines")
first_list_url = self.browser.current_url
MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines")
)
self.browser.find_element(By.LINK_TEXT, "Reticulate splines").click()
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, first_list_url)
)
self.browser.get(self.live_server_url)
list_page.add_list_item("Ribbon of death")
second_list_url = self.browser.current_url
self.browser.find_element(By.LINK_TEXT, "My lists").click()
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death")
)
MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click()
self.wait_for(
lambda: self.assertEqual(
self.browser.find_elements(By.LINK_TEXT, "My lists"),
[],
)
)

Some files were not shown because too many files have changed in this diff Show More