Compare commits

...

81 Commits

Author SHA1 Message Date
Disco DeDisco
ff7b71792f narrow desktop breakpoint constraint relaxed somewhat to accomodate more fringe-case window aspect ratios; #id_gear_btn now, like #id_kit_btn, restyles to contain --quaUser rgb value when menu is active; dashboard.html include ordering switched for #id_dash_applet_menu & #id_gear_btn, to fix an issue causing the menu to overlay the btn instead of the other way around
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-15 16:39:14 -04:00
Disco DeDisco
2e24175ec8 new apps.epic app migrations for token expiration & cooldown; reject token renamed to return token everywhere; new mapps.epic.models & .views for expiration & cooldown; new apps.dash.views to manage stacking of like Token types not just in the kit bag but in the Gameboard's Game Kit applet & in the Dashwallet's Tokens applet; Free Tokens now display correctly in kit bag; apps.lyric.admin now ensures superuser cannot grant Free Tokens without an expiration date; corresponding tests in .tests.integrated.test_admin.TokenAdminFormTest; screendumps occurring for every test, regardless of passfail status, after one fail fixed in FTs.base; FTs.test_gatekeeper.GameKitInsertTest.test_free_token_insert_via_kit_consumed_on_confirm, for test purposes only, ensures starting Free Token deleted before fresh one assigned w. full 7d expiration battery 2026-03-15 16:08:34 -04:00
Disco DeDisco
18ba242647 fixed fatal pipeline flaw by correcting game-kit.js dir from static/apps/scripts to apps/dashboard/static/apps/scripts/game-kit-js; the former folder is untracked by git, so successful local code changes never registered to CI static files
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-15 13:51:48 -04:00
Disco DeDisco
6d1b358b7c more pipeline troubleshooting, possible pointer-event attr solution for headless browser FTs
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-15 13:07:13 -04:00
Disco DeDisco
2140bd8206 changed _room.scss overflow to target html instead of body, hopefully fixing FTs in pipeline for real this time
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-15 02:27:10 -04:00
Disco DeDisco
52e171cb20 patched some local fails & errors; pipeline still expected to show cracks
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-15 02:22:07 -04:00
Disco DeDisco
74d1a43559 #id_dash_applet_menu now outside #id_applets_container to avoid clipping, other issues (FTs passed locally, but not in headless CI pipeline); selenium now calls wait_for when looking for is_displayed on kit bag menu (hopefully another CI fix)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-15 01:46:11 -04:00
Disco DeDisco
2d453dbc78 new _kit_bag_panel.html partial in core to allow user to manage equipped kit items from anywhere on site; #id_kit_btn moved from _footer.html partial directly into a base.html include; new trinket for superusers now incl. in apps.lyric.models; apps.gameboard.views handles this new type of PASS token; apps.epic.views allows payment with several different token types based on rarity & expiration hierarchy; kit bag and PASS functionality now handled in apps.dashboard.views; /kit-bag/ now pathed in .urls; styles abound; fully passing test suite (tho much work to be done, chiefly with stacking like coins in FEFO order)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-15 01:17:09 -04:00
Disco DeDisco
4baaa63430 new model fields & migrations for apps.epic & apps.lyric; new FTs, ITs & UTs passing
; some styling changes effected primarily to _gatekeetper.html modal
2026-03-14 22:00:16 -04:00
Disco DeDisco
26b6d4e7db fixed invite input field timeout, which would obey the refresh triggered by the modal every 3s
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-14 13:32:56 -04:00
Disco DeDisco
f4dfce826b filled some styling lacunae, including structural fixes to html re: gatekeeper gear menu
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-14 13:28:31 -04:00
Disco DeDisco
53d9f79476 fixed css class mismatch for coin slot token rejection, left from unevenly applied refactor in last push; pipeline should now be green
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-14 11:01:12 -04:00
Disco DeDisco
ed48d18c1d selector button.token-rails replaces .token-insert-btn to fix 8 broken FTs clogging the pipeline
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-14 02:25:51 -04:00
Disco DeDisco
f76c6d0fe5 various styling & structural changes to unify site themes; token-drop interaction changes across epic urls & views
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-14 02:03:44 -04:00
Disco DeDisco
d9feb80b2a js snippet displays dynamic ellipsis on loading-style token gatekeeper modals; tweaks to existing pythonic & test structure to accomodate 2026-03-14 01:14:05 -04:00
Disco DeDisco
d780115515 fixed modal UX issue; now persists as intended, until token cost met in all six slots 2026-03-14 00:34:07 -04:00
Disco DeDisco
af3523c9bb new _room_gear.html to manage room actions for various gamers (e.g., founders & guests); new _room.scss for gatekeeper styling (still flimsy); added new .btn-abandon Bl-btn palette to _button-pad.scss; new FTs & epic view ITs assert functionality (100 percent coverage, fully passing test suite) 2026-03-14 00:10:40 -04:00
Disco DeDisco
dddffd22d5 covered some test lacunae; gatekeeper now waits for +6 gamers to commit tokens to unblock game room 2026-03-13 22:51:42 -04:00
Disco DeDisco
e0d1f51bf1 new migrations in apps.epic app; new models, urls, views handle the founder of a New Game inviting a friend via email to a game gatekeeper; ea. may drop coin in any of up to 6 avail. slots; FTs & ITs passing 2026-03-13 18:37:19 -04:00
Disco DeDisco
6a42b91420 new migrations in apps.epic & apps.lyric apps; new Token fields of latter articulate upon Room model helper fns of former; new FTs, ITs & UTs capture new behavior accordingly; new template partial content in templates/apps/gameboard 2026-03-13 17:31:52 -04:00
Disco DeDisco
5773462b4c massive additions made thru somewhat new apps.epic.models, .urls, .views; new html page & partial in apps/gameboard; new apps.epic FT & ITs (all green); New Game applet now actually leads to game room feat. token-drop gatekeeper mechanism intended for 6 gamers 2026-03-13 00:31:17 -04:00
Disco DeDisco
681a1a4cd0 seeded apps.epic for backend gameboard logic; core.asgi & .settings now accomodate Channels via Redis; several new libraries in reqs to accomodate 2026-03-12 15:05:02 -04:00
Disco DeDisco
69fea65bf9 new core.runner helper to avoid local caching issues w. coverage tests; .settings, apps.dash.tests.ITs.test_wallet_views updated accordingly
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-12 14:23:09 -04:00
Disco DeDisco
068b99d030 added missing dunderinits to apps.applets.tests & .tests.integrated; some of the test_models ITs never were passing til now but never tested either; new apps.lyric.tests.integrated.test_models cover missing Applet model return
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-11 15:53:31 -04:00
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
178 changed files with 8303 additions and 572 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

@@ -2,13 +2,19 @@ asgiref==3.11.0
attrs==25.4.0 attrs==25.4.0
certifi==2025.11.12 certifi==2025.11.12
cffi==2.0.0 cffi==2.0.0
channels
channels-redis
charset-normalizer==3.4.4 charset-normalizer==3.4.4
coverage 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,17 +23,21 @@ 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
scipy
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
typing_extensions==4.15.0 typing_extensions==4.15.0
tzdata==2025.3 tzdata==2025.3
urllib3==2.6.2 urllib3==2.6.2
uvicorn[standard]
websocket-client==1.9.0 websocket-client==1.9.0
whitenoise==6.11.0 whitenoise==6.11.0
wsproto==1.3.2 wsproto==1.3.2

View File

@@ -1,10 +1,21 @@
celery
channels
channels-redis
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
scipy
stripe
whitenoise==6.11.0 whitenoise==6.11.0
uvicorn[standard]

View File

@@ -3,6 +3,7 @@ source = apps
omit = omit =
*/migrations/* */migrations/*
*/tests/* */tests/*
*/routing.py
[report] [report]
show_missing = true show_missing = true

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,41 @@
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;
const opening = menu.style.display === 'none' || menu.style.display === '';
menu.style.display = opening ? 'block' : 'none';
gear.classList.toggle('active', opening);
});
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';
gear.classList.remove('active');
}
});
})
};
document.addEventListener('DOMContentLoaded', initGearMenus);
const appletContainerIds = new Set([
'id_applets_container',
'id_game_applets_container',
'id_wallet_applets_container',
]);
document.body.addEventListener('htmx:afterSwap', (e) => {
if (!e.detail.target || !appletContainerIds.has(e.detail.target.id)) return;
document.querySelectorAll('.gear-btn').forEach(gear => {
const menu = document.getElementById(gear.dataset.menuTarget);
if (menu) menu.style.display = 'none';
gear.classList.remove('active');
});
});

View File

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.get_or_create(slug="username", defaults={"name": "Username", "context": "dashboard"})
self.game_applet, _ = Applet.objects.get_or_create(slug="new-game", defaults={"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

@@ -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

@@ -0,0 +1,26 @@
# Generated by Django 6.0 on 2026-03-12 19:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0002_rename_list_to_note'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='note',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='note',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_notes', to=settings.AUTH_USER_MODEL),
),
]

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,99 @@
(function () {
var btn = document.getElementById('id_kit_btn');
var dialog = document.getElementById('id_kit_bag_dialog');
if (!btn || !dialog) return;
dialog.addEventListener('close', function () {
btn.classList.remove('active');
clearSelection();
});
btn.addEventListener('click', function () {
if (btn.classList.contains('active')) {
dialog.removeAttribute('open');
btn.classList.remove('active');
clearSelection();
return;
}
fetch(btn.dataset.kitUrl, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
})
.then(function (r) { return r.text(); })
.then(function (html) {
dialog.innerHTML = html;
attachCardListeners();
btn.classList.add('active');
dialog.setAttribute('open', '');
})
.catch(function () {
btn.classList.remove('active');
});
});
// Escape key
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && dialog.open) {
dialog.close();
}
});
// Click outside (but not on the rails button — let that flow through)
document.addEventListener('click', function (e) {
if (!dialog.open) return;
if (dialog.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return;
if (e.target.closest('button.token-rails')) return;
dialog.close();
});
// Inject token_id before token-rails form submits
document.addEventListener('click', function (e) {
var rails = e.target.closest('button.token-rails');
if (!rails || !window._kitTokenId) return;
var form = rails.closest('form');
if (!form) return;
var existing = form.querySelector('input[name="token_id"]');
if (existing) existing.remove();
var hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'token_id';
hidden.value = window._kitTokenId;
form.appendChild(hidden);
if (dialog.open) dialog.close();
});
function attachCardListeners() {
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
card.addEventListener('click', function () {
dialog.querySelectorAll('.token[data-token-id].selected').forEach(function (c) {
c.classList.remove('selected');
});
card.classList.add('selected');
window._kitTokenId = card.dataset.tokenId;
var slot = document.querySelector('.token-slot');
if (slot) slot.classList.add('ready');
});
card.addEventListener('mouseenter', function () {
var tooltip = card.querySelector('.token-tooltip');
if (!tooltip) return;
var rect = card.getBoundingClientRect();
tooltip.style.position = 'fixed';
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
tooltip.style.left = rect.left + 'px';
tooltip.style.display = 'block';
});
card.addEventListener('mouseleave', function () {
var tooltip = card.querySelector('.token-tooltip');
if (tooltip) tooltip.style.display = '';
});
});
}
function clearSelection() {
window._kitTokenId = null;
var slot = document.querySelector('.token-slot');
if (slot) slot.classList.remove('ready');
}
}());

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,139 @@
import lxml.html
from django.test import TestCase
from apps.applets.models import Applet, UserApplet
from apps.lyric.models import Token, User, Wallet
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)
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")
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,16 @@ 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'),
path('kit-bag/', views.kit_bag, name='kit_bag'),
] ]

View File

@@ -1,48 +1,227 @@
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.utils import timezone
from .models import Item, List from django.views.decorators.csrf import ensure_csrf_cookie
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,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at")),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
"free_count": request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).count(),
"tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(),
"applets": applet_context(request.user, "wallet"),
"page_class": "page-wallet",
})
@login_required(login_url="/")
def kit_bag(request):
tokens = list(request.user.tokens.all())
free_tokens = sorted(
[t for t in tokens if t.token_type == Token.FREE and t.expires_at and t.expires_at > timezone.now()],
key=lambda t: t.expires_at,
)
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
return render(request, "core/_partials/_kit_bag_panel.html", {
"tokens": tokens,
"free_token": free_tokens[0] if free_tokens else None,
"free_count": len(free_tokens),
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
"tithe_count": len(tithe_tokens),
})
@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,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
"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

3
src/apps/epic/admin.py Normal file
View File

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

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

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

View File

@@ -0,0 +1 @@
# RoomConsumer goes here

0
src/apps/epic/forms.py Normal file
View File

View File

@@ -0,0 +1,45 @@
# Generated by Django 6.0 on 2026-03-12 19:46
import django.db.models.deletion
import uuid
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='Room',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('visibility', models.CharField(choices=[('PRIVATE', 'Private'), ('PUBLIC', 'Public'), ('INVITE ONLY', 'Invite Only')], default='PRIVATE', max_length=20)),
('gate_status', models.CharField(choices=[('GATHERING', 'Gathering'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20)),
('renewal_period', models.DurationField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('board_state', models.JSONField(default=dict)),
('seed_count', models.IntegerField(default=12)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_rooms', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='GateSlot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slot_number', models.IntegerField()),
('status', models.CharField(choices=[('EMPTY', 'Empty'), ('RESERVED', 'Reserved'), ('FILLED', 'Filled')], default='EMPTY', max_length=10)),
('reserved_at', models.DateTimeField(blank=True, null=True)),
('filled_at', models.DateTimeField(blank=True, null=True)),
('funded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='funded_slots', to=settings.AUTH_USER_MODEL)),
('gamer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='gate_slots', to=settings.AUTH_USER_MODEL)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gate_slots', to='epic.room')),
],
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 6.0 on 2026-03-13 20:32
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='room',
name='renewal_period',
field=models.DurationField(blank=True, default=datetime.timedelta(days=7), null=True),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 6.0 on 2026-03-13 22:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0002_alter_room_renewal_period'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RoomInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invitee_email', models.EmailField(max_length=254)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('DECLINED', 'Declined')], default='PENDING', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_invites', to=settings.AUTH_USER_MODEL)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='epic.room')),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-03-15 00:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0003_roominvite'),
]
operations = [
migrations.AlterField(
model_name='room',
name='gate_status',
field=models.CharField(choices=[('GATHERING', 'GATHERING GAMERS'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0004_alter_room_gate_status'),
]
operations = [
migrations.AddField(
model_name='gateslot',
name='debited_token_type',
field=models.CharField(max_length=8, null=True, blank=True),
),
migrations.AddField(
model_name='gateslot',
name='debited_token_expires_at',
field=models.DateTimeField(null=True, blank=True),
),
]

View File

133
src/apps/epic/models.py Normal file
View File

@@ -0,0 +1,133 @@
import uuid
from datetime import timedelta
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from django.utils import timezone
from apps.lyric.models import Token
class Room(models.Model):
GATHERING = "GATHERING"
OPEN = "OPEN"
RENEWAL_DUE = "RENEWAL_DUE"
GATE_STATUS_CHOICES = [
(GATHERING, "GATHERING GAMERS"),
(OPEN, "Open"),
(RENEWAL_DUE, "Renewal Due"),
]
PRIVATE = "PRIVATE"
PUBLIC = "PUBLIC"
INVITE_ONLY = "INVITE ONLY"
VISIBILITY_CHOICES = [
(PRIVATE, "Private"),
(PUBLIC, "Public"),
(INVITE_ONLY, "Invite Only"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_rooms"
)
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING)
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
created_at = models.DateTimeField(auto_now_add=True)
board_state = models.JSONField(default=dict)
seed_count = models.IntegerField(default=12)
class GateSlot(models.Model):
EMPTY = "EMPTY"
RESERVED = "RESERVED"
FILLED = "FILLED"
STATUS_CHOICES = [
(EMPTY, "Empty"),
(RESERVED, "Reserved"),
(FILLED, "Filled"),
]
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="gate_slots")
slot_number = models.IntegerField()
gamer = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="gate_slots"
)
funded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="funded_slots"
)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY)
reserved_at = models.DateTimeField(null=True, blank=True)
filled_at = models.DateTimeField(null=True, blank=True)
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
class RoomInvite(models.Model):
PENDING = "PENDING"
ACCEPTED = "ACCEPTED"
DECLINED = "DECLINED"
STATUS_CHOICES = [
(PENDING, "Pending"),
(ACCEPTED, "Accepted"),
(DECLINED, "Declined"),
]
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="invites")
inviter = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="sent_invites"
)
invitee_email = models.EmailField()
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
created_at = models.DateTimeField(auto_now_add=True)
@receiver(post_save, sender=Room)
def create_gate_slots(sender, instance, created, **kwargs):
if created:
for i in range(1, 7):
GateSlot.objects.create(room=instance, slot_number=i)
def select_token(user):
if user.is_staff:
pass_token = user.tokens.filter(token_type=Token.PASS).first()
if pass_token:
return pass_token
coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first()
if coin:
return coin
free = user.tokens.filter(
token_type=Token.FREE,
expires_at__gt=timezone.now(),
).order_by("expires_at").first()
if free:
return free
return user.tokens.filter(token_type=Token.TITHE).first()
def debit_token(user, slot, token):
slot.debited_token_type = token.token_type
if token.token_type == Token.COIN:
token.current_room = slot.room
period = slot.room.renewal_period or timedelta(days=7)
token.next_ready_at = timezone.now() + period
token.save()
elif token.token_type != Token.PASS:
slot.debited_token_expires_at = token.expires_at
token.delete()
slot.gamer = user
slot.status = GateSlot.FILLED
slot.filled_at = timezone.now()
slot.save()
room = slot.room
if not room.gate_slots.filter(status=GateSlot.EMPTY).exists():
room.gate_status = Room.OPEN
room.save()

1
src/apps/epic/routing.py Normal file
View File

@@ -0,0 +1 @@
websocket_urlpatterns = []

View File

View File

@@ -0,0 +1,160 @@
from datetime import timedelta
from django.db.models import Q
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.lyric.models import Token, User
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
class RoomCreationTest(TestCase):
def test_creating_a_room_generates_six_gate_slots(self):
owner = User.objects.create(email="founder@example.com")
room = Room.objects.create(name="Test Room", owner=owner)
self.assertEqual(GateSlot.objects.filter(room=room).count(), 6)
class DebitTokenTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(
name="Test Room",
owner=self.owner,
renewal_period=timedelta(days=7)
)
self.slot = self.room.gate_slots.get(slot_number=1)
def test_debit_free_token_consumes_token_and_fills_slot(self):
free_token = Token.objects.get(user=self.owner, token_type=Token.FREE)
debit_token(self.owner, self.slot, free_token)
self.assertFalse(Token.objects.filter(pk=free_token.pk).exists())
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
self.assertEqual(self.slot.gamer, self.owner)
def test_debit_coin_does_not_consume_token(self):
coin_token = Token.objects.get(user=self.owner, token_type=Token.COIN)
debit_token(self.owner, self.slot, coin_token)
self.assertTrue(Token.objects.filter(pk=coin_token.pk).exists())
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
self.assertEqual(self.slot.gamer, self.owner)
def test_debit_fills_last_slot_and_opens_gate(self):
for i in range(2, 7):
gamer = User.objects.create(email=f"g{i}@test.io")
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
free_token = Token.objects.get(user=self.owner, token_type=Token.FREE)
debit_token(self.owner, self.slot, free_token)
self.room.refresh_from_db()
self.assertEqual(self.room.gate_status, Room.OPEN)
class CoinTokenInUseTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(
name="Dragon's Den",
owner=self.owner,
renewal_period=timedelta(days=7),
)
self.slot = self.room.gate_slots.get(slot_number=1)
self.coin = Token.objects.get(user=self.owner, token_type=Token.COIN)
debit_token(self.owner, self.slot, self.coin)
self.coin.refresh_from_db()
def test_coin_tooltip_expiry_shows_next_ready_date(self):
expected_date = self.coin.next_ready_at.strftime("%Y-%m-%d")
self.assertIn(expected_date, self.coin.tooltip_expiry())
def test_coin_tooltip_room_html_contains_anchor(self):
room_url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
html = self.coin.tooltip_room_html()
self.assertIn(f'href="{room_url}"', html)
self.assertIn(self.room.name, html)
class SelectTokenTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.other_room = Room.objects.create(name="Other Room", owner=self.user)
self.coin = Token.objects.get(user=self.user, token_type=Token.COIN)
def test_returns_coin_when_available(self):
token = select_token(self.user)
self.assertEqual(token.token_type, Token.COIN)
def test_returns_free_token_when_coin_in_use(self):
self.coin.current_room = self.other_room
self.coin.save()
token = select_token(self.user)
self.assertEqual(token.token_type, Token.FREE)
def test_free_token_selection_is_fefo(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
soon = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=2),
)
Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=6),
)
token = select_token(self.user)
self.assertEqual(token.pk, soon.pk)
def test_returns_tithe_when_coin_in_use_and_no_free_tokens(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
token = select_token(self.user)
self.assertEqual(token.pk, tithe.pk)
def test_returns_none_when_all_depleted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
token = select_token(self.user)
self.assertIsNone(token)
def test_returns_pass_for_staff(self):
self.user.is_staff = True
self.user.save()
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
token = select_token(self.user)
self.assertEqual(token.token_type, Token.PASS)
class RoomInviteTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(name="Dragon's Den", owner=self.founder)
def test_founder_can_invite_by_email(self):
invite = RoomInvite.objects.create(
room=self.room,
inviter=self.founder,
invitee_email="friend@example.com",
)
self.assertEqual(invite.status, RoomInvite.PENDING)
def test_invited_room_appears_in_my_games_queryset(self):
friend = User.objects.create(email="friend@example.com")
RoomInvite.objects.create(
room=self.room,
inviter=self.founder,
invitee_email=friend.email,
)
rooms = Room.objects.filter(
Q(owner=friend) |
Q(gate_slots__gamer=friend) |
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
).distinct()
self.assertIn(self.room, rooms)

View File

@@ -0,0 +1,400 @@
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.lyric.models import Token, User
from apps.epic.models import GateSlot, Room, RoomInvite
class RoomCreationViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="founder@test.io")
self.client.force_login(self.user)
def test_post_creates_room_and_redirects_to_gatekeeper(self):
response = self.client.post(
reverse("epic:create_room"),
data={"name": "Test Room"},
)
room = Room.objects.get(owner=self.user)
self.assertRedirects(
response, reverse(
"epic:gatekeeper",
args=[room.id],
)
)
def test_post_requires_login(self):
self.client.logout()
response = self.client.post(
reverse("epic:create_room"),
data={"name": "Test Room"},
)
def test_create_room_get_redirects_to_gameboard(self):
response = self.client.get(reverse("epic:create_room"))
self.assertRedirects(response, "/gameboard/")
class MyGamesContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@example.com")
self.client.force_login(self.user)
def test_gameboard_context_includes_owned_rooms(self):
room = Room.objects.create(name="Durango", owner=self.user)
response = self.client.get("/gameboard/")
self.assertIn(room, response.context["my_games"])
def test_gameboard_context_includes_rooms_with_filled_slot(self):
other = User.objects.create(email="friend@example.com")
room = Room.objects.create(name="Their Room", owner=other)
slot = room.gate_slots.get(slot_number=2)
slot.gamer = self.user
slot.status = "FILLED"
slot.save()
response = self.client.get("/gameboard/")
self.assertIn(room, response.context["my_games"])
class GateStatusViewTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@test.io")
self.client.force_login(self.owner)
self.room = Room.objects.create(name="Test Room", owner=self.owner)
def test_gate_status_returns_empty_when_open(self):
self.room.gate_status = Room.OPEN
self.room.save()
response = self.client.get(reverse("epic:gate_status", kwargs={"room_id": self.room.id}))
self.assertEqual(response.content, b"")
def test_gate_status_returns_partial_when_gathering(self):
response = self.client.get(
reverse("epic:gate_status", kwargs={"room_id": self.room.id})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "gate-modal")
class DropTokenViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
def test_drop_token_reserves_lowest_empty_slot(self):
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot = self.room.gate_slots.get(slot_number=1)
self.assertEqual(slot.status, GateSlot.RESERVED)
self.assertEqual(slot.gamer, self.gamer)
def test_drop_token_skips_already_filled_slots(self):
other = User.objects.create(email="other@test.io")
slot1 = self.room.gate_slots.get(slot_number=1)
slot1.gamer = other
slot1.status = GateSlot.FILLED
slot1.save()
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot2 = self.room.gate_slots.get(slot_number=2)
self.assertEqual(slot2.status, GateSlot.RESERVED)
self.assertEqual(slot2.gamer, self.gamer)
def test_drop_token_blocked_when_another_slot_reserved(self):
other = User.objects.create(email="other@test.io")
slot1 = self.room.gate_slots.get(slot_number=1)
slot1.gamer = other
slot1.status = GateSlot.RESERVED
slot1.reserved_at = timezone.now()
slot1.save()
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
# Slot 2 should remain EMPTY — lock held by other user
slot2 = self.room.gate_slots.get(slot_number=2)
self.assertEqual(slot2.status, GateSlot.EMPTY)
def test_drop_token_blocked_when_user_already_has_filled_slot(self):
slot1 = self.room.gate_slots.get(slot_number=1)
slot1.gamer = self.gamer
slot1.status = GateSlot.FILLED
slot1.save()
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot2 = self.room.gate_slots.get(slot_number=2)
self.assertEqual(slot2.status, GateSlot.EMPTY)
def test_drop_token_sets_reserved_at(self):
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot = self.room.gate_slots.get(slot_number=1)
self.assertIsNotNone(slot.reserved_at)
def test_drop_token_redirects_to_gatekeeper(self):
response = self.client.post(
reverse("epic:drop_token", kwargs={"room_id": self.room.id})
)
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
class ConfirmTokenViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.gamer
self.slot.status = GateSlot.RESERVED
self.slot.reserved_at = timezone.now()
self.slot.save()
Token.objects.create(user=self.gamer, token_type=Token.FREE)
def test_confirm_marks_slot_filled(self):
self.client.post(
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
def test_confirm_sets_gate_open_when_all_slots_filled(self):
# Fill slots 26 via ORM
for i in range(2, 7):
other = User.objects.create(email=f"g{i}@test.io")
s = self.room.gate_slots.get(slot_number=i)
s.gamer = other
s.status = GateSlot.FILLED
s.save()
self.client.post(
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
)
self.room.refresh_from_db()
self.assertEqual(self.room.gate_status, Room.OPEN)
def test_confirm_redirects_to_gatekeeper(self):
response = self.client.post(
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
)
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
def test_confirm_does_nothing_without_reserved_slot(self):
self.slot.status = GateSlot.EMPTY
self.slot.gamer = None
self.slot.save()
self.client.post(
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
class ReturnTokenViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.gamer
self.slot.status = GateSlot.RESERVED
self.slot.reserved_at = timezone.now()
self.slot.save()
def test_return_clears_reserved_slot(self):
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
self.assertIsNone(self.slot.gamer)
self.assertIsNone(self.slot.reserved_at)
def test_return_after_confirm_clears_filled_slot(self):
self.slot.status = GateSlot.FILLED
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
self.assertIsNone(self.slot.gamer)
def test_return_redirects_to_gatekeeper(self):
response = self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
def test_return_restores_coin_token(self):
coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
coin.current_room = self.room
coin.next_ready_at = timezone.now() + timedelta(days=7)
coin.save()
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.COIN
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
coin.refresh_from_db()
self.assertIsNone(coin.current_room)
self.assertIsNone(coin.next_ready_at)
def test_return_restores_free_token(self):
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
expires = timezone.now() + timedelta(days=3)
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.FREE
self.slot.debited_token_expires_at = expires
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
restored = Token.objects.filter(user=self.gamer, token_type=Token.FREE).first()
self.assertIsNotNone(restored)
self.assertEqual(restored.expires_at, expires)
def test_return_restores_tithe_token(self):
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.TITHE
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
self.assertTrue(
Token.objects.filter(user=self.gamer, token_type=Token.TITHE).exists()
)
class DropTokenAvailabilityViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
self.other_room = Room.objects.create(name="Other Room", owner=owner)
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
def test_drop_reserves_slot_when_tokens_available(self):
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot = self.room.gate_slots.get(slot_number=1)
self.assertEqual(slot.status, GateSlot.RESERVED)
# token not debited yet — that happens at confirm
self.coin.refresh_from_db()
self.assertIsNone(self.coin.current_room)
def test_drop_returns_402_when_all_tokens_depleted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
response = self.client.post(
reverse("epic:drop_token", kwargs={"room_id": self.room.id})
)
self.assertEqual(response.status_code, 402)
class ConfirmTokenPriorityViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
self.other_room = Room.objects.create(name="Other Room", owner=owner)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.gamer
self.slot.status = GateSlot.RESERVED
self.slot.reserved_at = timezone.now()
self.slot.save()
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
def test_confirm_leases_coin_to_room(self):
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, self.room)
self.assertTrue(Token.objects.filter(pk=self.coin.pk).exists())
def test_confirm_uses_free_token_when_coin_in_use(self):
self.coin.current_room = self.other_room
self.coin.save()
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertEqual(
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0
)
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, self.other_room)
def test_confirm_uses_tithe_when_free_tokens_exhausted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE)
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
def test_pass_not_consumed_and_coin_not_leased(self):
self.gamer.is_staff = True
self.gamer.save()
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
self.coin.refresh_from_db()
self.assertIsNone(self.coin.current_room)
class RoomActionsViewTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="owner@test.io")
self.gamer = User.objects.create(email="gamer@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.owner)
self.slot = self.room.gate_slots.get(slot_number=2)
self.slot.gamer = self.gamer
self.slot.status = "FILLED"
self.slot.save()
RoomInvite.objects.create(
room=self.room, inviter=self.owner,
invitee_email=self.gamer.email
)
def test_owner_delete_removes_room(self):
self.client.force_login(self.owner)
self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
self.assertFalse(Room.objects.filter(pk=self.room.pk).exists())
def test_non_owner_delete_does_not_remove_room(self):
self.client.force_login(self.gamer)
self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
self.assertTrue(Room.objects.filter(pk=self.room.pk).exists())
def test_delete_redirects_to_gameboard(self):
self.client.force_login(self.owner)
response = self.client.post(
reverse("epic:delete_room", kwargs={"room_id": self.room.id})
)
self.assertRedirects(response, "/gameboard/")
def test_abandon_clears_slot(self):
self.client.force_login(self.gamer)
self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, "EMPTY")
self.assertIsNone(self.slot.gamer)
def test_abandon_deletes_pending_invite(self):
self.client.force_login(self.gamer)
self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
self.assertFalse(
RoomInvite.objects.filter(
room=self.room, invitee_email=self.gamer.email
).exists()
)
def test_abandon_redirects_to_gameboard(self):
self.client.force_login(self.gamer)
response = self.client.post(
reverse("epic:abandon_room", kwargs={"room_id": self.room.id})
)
self.assertRedirects(response, "/gameboard/")

View File

17
src/apps/epic/urls.py Normal file
View File

@@ -0,0 +1,17 @@
from django.urls import path
from . import views
app_name = 'epic'
urlpatterns = [
path('rooms/create_room', views.create_room, name='create_room'),
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
]

199
src/apps/epic/views.py Normal file
View File

@@ -0,0 +1,199 @@
from datetime import timedelta
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
from apps.lyric.models import Token
RESERVE_TIMEOUT = timedelta(seconds=60)
def _expire_reserved_slots(room):
cutoff = timezone.now() - RESERVE_TIMEOUT
room.gate_slots.filter(
status=GateSlot.RESERVED,
reserved_at__lt=cutoff,
).update(status=GateSlot.EMPTY, gamer=None, reserved_at=None)
def _gate_context(room, user):
_expire_reserved_slots(room)
slots = room.gate_slots.order_by("slot_number")
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
user_reserved_slot = None
user_filled_slot = None
if user.is_authenticated:
user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first()
eligible = (
user.is_authenticated
and pending_slot is None
and user_reserved_slot is None
and user_filled_slot is None
)
token_depleted = eligible and select_token(user) is None
can_drop = eligible and not token_depleted
is_last_slot = (
user_reserved_slot is not None
and slots.filter(status=GateSlot.EMPTY).count() == 0
)
user_can_reject = user_reserved_slot is not None or user_filled_slot is not None
return {
"slots": slots,
"pending_slot": pending_slot,
"user_reserved_slot": user_reserved_slot,
"user_filled_slot": user_filled_slot,
"can_drop": can_drop,
"token_depleted": token_depleted,
"is_last_slot": is_last_slot,
"user_can_reject": user_can_reject,
}
@login_required
def create_room(request):
if request.method == "POST":
name = request.POST.get("name", "").strip()
if name:
room = Room.objects.create(name=name, owner=request.user)
return redirect("epic:gatekeeper", room_id=room.id)
return redirect("/gameboard/")
def gatekeeper(request, room_id):
room = Room.objects.get(id=room_id)
ctx = _gate_context(room, request.user)
ctx["room"] = room
return render(request, "apps/gameboard/room.html", ctx)
@login_required
def drop_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
token_id = request.POST.get("token_id")
if token_id:
token = request.user.tokens.filter(id=token_id).first()
else:
token = select_token(request.user)
if token is None:
return HttpResponse(status=402)
slot = room.gate_slots.filter(
status=GateSlot.EMPTY
).order_by("slot_number").first()
if slot:
slot.gamer = request.user
slot.status = GateSlot.RESERVED
slot.reserved_at = timezone.now()
slot.save()
request.session["kit_token_id"] = str(token.id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def confirm_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.RESERVED
).first()
if slot:
token_id = request.session.pop("kit_token_id", None)
token = None
if token_id:
token = request.user.tokens.filter(id=token_id).first()
if not token:
token = select_token(request.user)
if token:
debit_token(request.user, slot, token)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def return_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot = room.gate_slots.filter(
gamer=request.user,
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
).first()
if slot:
if slot.status == GateSlot.FILLED:
if slot.debited_token_type == Token.COIN:
coin = request.user.tokens.filter(
token_type=Token.COIN, current_room=room
).first()
if coin:
coin.current_room = None
coin.next_ready_at = None
coin.save()
elif slot.debited_token_type in (Token.FREE, Token.TITHE):
Token.objects.create(
user=request.user,
token_type=slot.debited_token_type,
expires_at=slot.debited_token_expires_at,
)
request.session.pop("kit_token_id", None)
slot.gamer = None
slot.status = GateSlot.EMPTY
slot.reserved_at = None
slot.filled_at = None
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save()
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def invite_gamer(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
email = request.POST.get("invitee_email", "").strip()
if email:
RoomInvite.objects.get_or_create(
room=room,
inviter=request.user,
invitee_email=email,
defaults={"status": RoomInvite.PENDING}
)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def delete_room(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if request.user == room.owner:
room.delete()
return redirect("/gameboard/")
@login_required
def abandon_room(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
room.gate_slots.filter(gamer=request.user).update(
gamer=None, status="EMPTY", filled_at=None
)
room.invites.filter(
invitee_email=request.user.email,
status=RoomInvite.PENDING
).delete()
return redirect("/gameboard/")
def gate_status(request, room_id):
room = Room.objects.get(id=room_id)
if room.gate_status == Room.OPEN:
return HttpResponse("")
ctx = _gate_context(room, request.user)
ctx["room"] = room
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)

1
src/apps/epic/voronoi.py Normal file
View File

@@ -0,0 +1 @@
# TODO: toroidal topology (tile seeds across boundary before computing)

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,68 @@
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.shortcuts import redirect, render
from django.utils import timezone
from apps.applets.utils import applet_context
from apps.applets.models import Applet, UserApplet
from apps.epic.models import Room, RoomInvite
from apps.lyric.models import Token
GAMEBOARD_APPLET_ORDER = [
"new-game",
"my-games",
"game-kit",
]
@login_required(login_url="/")
def gameboard(request):
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
coin = request.user.tokens.filter(token_type=Token.COIN).first()
free_tokens = list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at"))
return render(
request, "apps/gameboard/gameboard.html", {
"pass_token": pass_token,
"coin": coin,
"free_tokens": free_tokens,
"free_count": len(free_tokens),
"applets": applet_context(request.user, "gameboard"),
"page_class": "page-gameboard",
"my_games": Room.objects.filter(
Q(owner=request.user) |
Q(gate_slots__gamer=request.user) |
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
).distinct(),
}
)
@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"),
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at")),
"free_count": request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).count(),
"my_games": Room.objects.filter(
Q(owner=request.user) |
Q(gate_slots__gamer=request.user) |
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
).distinct(),
})
return redirect("gameboard")

View File

@@ -1,6 +1,30 @@
from django import forms
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):
admin.site.register(Token) list_display = ["email"]
search_fields = ["email"]
class TokenAdminForm(forms.ModelForm):
class Meta:
model = Token
fields = "__all__"
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("token_type") == Token.FREE and not cleaned_data.get("expires_at"):
raise forms.ValidationError("Free Tokens must have an expiration date.")
return cleaned_data
class TokenAdmin(admin.ModelAdmin):
form = TokenAdminForm
admin.site.register(User, UserAdmin)
admin.site.register(LoginToken)
admin.site.register(Token, TokenAdmin)

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

@@ -0,0 +1,25 @@
# Generated by Django 6.0 on 2026-03-13 20:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0001_initial'),
('lyric', '0007_user_stripe_customer_id_paymentmethod'),
]
operations = [
migrations.AddField(
model_name='token',
name='current_room',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='coin_tokens', to='epic.room'),
),
migrations.AddField(
model_name='token',
name='next_ready_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-03-15 00:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0008_token_current_room_token_next_ready_at'),
]
operations = [
migrations.AlterField(
model_name='token',
name='token_type',
field=models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token'), ('pass', 'Backstage Pass')], max_length=8),
),
]

View File

@@ -1,7 +1,12 @@
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.urls import reverse
from django.utils import timezone
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
@@ -17,13 +22,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 +46,106 @@ 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"
PASS = "pass"
TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"),
(FREE, "Free Token"),
(TITHE, "Tithe Token"),
(PASS, "Backstage Pass"),
]
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)
current_room = models.ForeignKey(
"epic.Room", null=True, blank=True,
on_delete=models.SET_NULL, related_name="coin_tokens"
)
next_ready_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, self.PASS):
return "Admit 1 Entry"
if self.token_type == self.TITHE:
return "+ Writ bonus"
return ""
def tooltip_expiry(self):
if self.token_type in (self.COIN, self.PASS):
if self.token_type == self.COIN and self.next_ready_at:
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
return "no expiry"
if self.expires_at:
return f"Expires {self.expires_at.strftime('%Y-%m-%d')}"
return ""
def tooltip_room_html(self):
if not self.current_room_id:
return ""
url = reverse("epic:gatekeeper", kwargs={"room_id": self.current_room_id})
return f'<a href="{url}">{self.current_room.name}</a>'
def tooltip_shoptalk(self):
if self.token_type == self.COIN:
return "\u2026and another after that, and another after that\u2026"
if self.token_type == self.PASS:
return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?"
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),
)
if instance.is_staff:
Token.objects.create(user=instance, token_type=Token.PASS)

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}")

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