Compare commits

..

38 Commits

Author SHA1 Message Date
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
83 changed files with 2918 additions and 233 deletions

View File

@@ -6,31 +6,44 @@ 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
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 +56,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 +71,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,10 @@
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

View File

@@ -1,23 +1,28 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
33616230376431343735626631623932393166343538653732383533323436326335343463646664 65383061626464353936363564313761663834646361326362613934363565623234636337313363
6565373531623465613661613533376231373837326438300a393665613839646231633737313938 3933313962643261353830333463336166393030313936370a616234626135633432613366633363
64633035336663313163333634623732323537326363646132313136376131636666636538323066 61633265363937326231623365646336333737306634646335376135633031643564666164336230
3037373930303537320a313062646166353862633836373466316261363939633433663039323866 3435353764383936620a396165386538666433356166383661323037333861373632376432313332
62333739303662343836306538393734343830366336323265393138343438363533353166383031 66666236373462363236663335623734633364653539323331396361613738636166323134386466
32313461313137643039376237346633316466646136353038633861333031663164656233366634 66656431663261633036333537373336643866623236643139656662333831366435373837656262
38303363383130376264373861393863623330623733643135643461383132613339376633353031 36333734376363373462643239623437623735373935633732343639313666663436616630363933
32313863323039646534633733383661333361313832333830383066633130396239626661643264 61396530336461393064323161666537646135383462383532363932326132363331633438313138
65636335303339613432326533343337366261356632313639623634386633383836333733663536 61623431326537313637626239653038353263313731303262653537316134383264616661623962
39383361353530646166643531333535356636326535383534326237666638326137616162646261 32333564366362383431336432303964663835363365636434303332613161363930333065336637
65316466323335653932636338653565383038313531383638393839313736643739363037353230 33343466343062306434663765613837343635386630326439303739616166396134393939626434
35653632353531656435396663316537333133653632366437613339303033333536643937353166 62336634303963653230626630636363343730623734626336363039623231633532653330646366
64363037653733303332643931343362303261643432366531326262383465313965633064356338 66613432633834393133386666623466326131386633303264333766306135623337353433306632
31336333373665373035656533633864316139303934623030383934393434356334643962666163 66323733373232383862646661313966366465333463366361366337656537623562613964666631
33343739366336613263333764306365333566363536616662383733616237396563346132336633 65373566316432383134666434393338626138363632633766636561383263333636623530326664
38663239613339376335386233386330396634323033343332366130616162666339393861306336 63333265366132376437396431393535323931383637323833303839336635633735333565333530
35383566383831356530633130313732356331616164646132626665646235396635386237313538 65343263373630633063383931646163323237643436366566363932646566323539373136646433
38656631336261646530303761643334303937613036363766303637376262373466316431323731 37623638333834373537316164633166633738333363656431356163623332396631353864333333
38666462313639353131303134646434646135366136343361353932326165626666306361393431 33306666646532626636376239326438373737383432663539333736363866663938396136383035
62646238323265346263386363373462313766616333326366366461346436383064336535376339 32343534613862653538346430313338326435356230636535343464666262626663376635363835
31356566356336386262393831616631666233633930393263623563386265343237323133313832 65363862663461353464313533313333323863313539643533343431643130383663656161616131
3430363635363332303963316530663765613666306233376463 33323639333564383830346163386362386238323936393832623961646565613961356263356365
65376431666130356564666236383764316136326366666661326538653133343165326431393564
36303065366263316232663230343137333231346538633036613066643365616331336135376461
35613265623134663633303238366363336137383436663836353863623533396236666433303738
38356361653633323065303035376664326238633066623731623436333332373363636634323433
393631303539373234386465663630316335

View File

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

View File

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

View File

@@ -7,8 +7,12 @@ coverage
cssselect==1.3.0 cssselect==1.3.0
dj-database-url dj-database-url
Django==6.0 Django==6.0
django-compressor
django-htmx
django-libsass
django-stubs==5.2.8 django-stubs==5.2.8
django-stubs-ext==5.2.8 django-stubs-ext==5.2.8
djangorestframework
gunicorn==23.0.0 gunicorn==23.0.0
h11==0.16.0 h11==0.16.0
idna==3.11 idna==3.11

View File

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

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

View File

@@ -0,0 +1,32 @@
from rest_framework import serializers
from apps.dashboard.models import Item, List
from apps.lyric.models import User
class ItemSerializer(serializers.ModelSerializer):
text = serializers.CharField()
def validate_text(self, value):
list_ = self.context["list"]
if list_.item_set.filter(text=value).exists():
raise serializers.ValidationError("duplicate")
return value
class Meta:
model = Item
fields = ["id", "text"]
class ListSerializer(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 = List
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, List
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 ListDetailAPITest(BaseAPITest):
def test_returns_list_with_items(self):
list_ = List.objects.create(owner=self.user)
Item.objects.create(text="item 1", list=list_)
Item.objects.create(text="item 2", list=list_)
response = self.client.get(f"/api/lists/{list_.id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["id"], str(list_.id))
self.assertEqual(len(response.data["items"]), 2)
class ListItemsAPITest(BaseAPITest):
def test_can_add_item_to_list(self):
list_ = List.objects.create(owner=self.user)
response = self.client.post(
f"/api/lists/{list_.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_list(self):
list_ = List.objects.create(owner=self.user)
response = self.client.post(
f"/api/lists/{list_.id}/items/",
{"text": ""},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(Item.objects.count(), 0)
def test_cannot_add_duplicate_item_to_list(self):
list_ = List.objects.create(owner=self.user)
Item.objects.create(text="list item", list=list_)
duplicate_response = self.client.post(
f"/api/lists/{list_.id}/items/",
{"text": "list item"},
)
self.assertEqual(duplicate_response.status_code, 400)
self.assertEqual(Item.objects.count(), 1)
class ListsAPITest(BaseAPITest):
def test_get_returns_only_users_lists(self):
list1 = List.objects.create(owner=self.user)
Item.objects.create(text="item 1", list=list1)
other_user = User.objects.create_user("other@example.com")
List.objects.create(owner=other_user)
response = self.client.get("/api/lists/")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["id"], str(list1.id))
def test_post_creates_list_with_item(self):
response = self.client.post(
"/api/lists/",
{"text": "first item"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(List.objects.count(), 1)
self.assertEqual(List.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,20 @@
from django.test import SimpleTestCase
from apps.api.serializers import ItemSerializer, ListSerializer
class ItemSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = ItemSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "text"},
)
class ListSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = ListSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "name", "url", "items"},
)

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

@@ -0,0 +1,12 @@
from django.urls import path
from . import views
urlpatterns = [
path('lists/', views.ListsAPI.as_view(), name='api_lists'),
path('lists/<uuid:list_id>/', views.ListDetailAPI.as_view(), name='api_list_detail'),
path('lists/<uuid:list_id>/items/', views.ListItemsAPI.as_view(), name='api_list_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, ListSerializer, UserSerializer
from apps.dashboard.models import Item, List
from apps.lyric.models import User
class ListDetailAPI(APIView):
def get(self, request, list_id):
list_ = get_object_or_404(List, id=list_id)
serializer = ListSerializer(list_)
return Response(serializer.data)
class ListItemsAPI(APIView):
def post(self, request, list_id):
list_ = get_object_or_404(List, id=list_id)
serializer = ItemSerializer(data=request.data, context={"list": list_})
if serializer.is_valid():
serializer.save(list=list_)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class ListsAPI(APIView):
def get(self, request):
lists = List.objects.filter(owner=request.user)
serializer = ListSerializer(lists, many=True)
return Response(serializer.data)
def post(self, request):
list_ = List.objects.create(owner=request.user)
item = Item.objects.create(text=request.data.get("text", ""), list=list_)
serializer = ListSerializer(list_)
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

@@ -1,3 +1,11 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from apps.dashboard.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)

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

@@ -0,0 +1,37 @@
# Generated by Django 6.0 on 2026-03-04 20:34
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.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)),
('default_visible', models.BooleanField(default=True)),
],
),
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='dashboard.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

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

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

@@ -0,0 +1,17 @@
from django.db import migrations
def seed_applets(apps, schema_editor):
Applet = apps.get_model("dashboard", "Applet")
Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
Applet.objects.get_or_create(slug="theme-switcher", defaults={"name": "Theme Switcher"})
class Migration(migrations.Migration):
dependencies = [
("dashboard", "0002_applet_userapplet"),
]
operations = [
migrations.RunPython(seed_applets, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0 on 2026-03-06 22:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0003_seed_applets'),
]
operations = [
migrations.AddField(
model_name='applet',
name='grid_cols',
field=models.PositiveSmallIntegerField(default=12),
),
migrations.AddField(
model_name='applet',
name='grid_rows',
field=models.PositiveSmallIntegerField(default=3),
),
]

View File

@@ -0,0 +1,17 @@
from django.db import migrations
def set_grid_defaults(apps, schema_editor):
Applet = apps.get_model("dashboard", "Applet")
Applet.objects.filter(slug__in=["username", "theme-switcher"]).update(grid_cols=6, grid_rows=3)
Applet.objects.get_or_create(slug="new-list", defaults={"name": "New List", "grid_cols": 9, "grid_rows": 3})
Applet.objects.get_or_create(slug="my-lists", defaults={"name": "My Lists", "grid_cols": 3, "grid_rows": 3})
class Migration(migrations.Migration):
dependencies = [
("dashboard", "0004_applet_grid_cols_applet_grid_rows"),
]
operations = [
migrations.RunPython(set_grid_defaults, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,18 @@
from django.db import migrations
def rename_theme_switcher(apps, schema_editor):
Applet = apps.get_model("dashboard", "Applet")
Applet.objects.filter(slug="theme-switcher").update(
slug="palette", name="Palette", grid_cols=6, grid_rows=3
)
class Migration(migrations.Migration):
dependencies = [
("dashboard", "0005_set_applet_grid_defaults"),
]
operations = [
migrations.RunPython(rename_theme_switcher, migrations.RunPython.noop),
]

View File

@@ -1,7 +1,11 @@
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 List(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="lists",
@@ -34,3 +38,28 @@ class Item(models.Model):
def __str__(self): def __str__(self):
return self.text return self.text
class Applet(models.Model):
slug = models.SlugField(unique=True)
name = models.CharField(max_length=100)
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

@@ -2,8 +2,29 @@
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");
}; };
};
const initGearMenu = () => {
const gear = document.getElementById('id_dash_gear');
if (!gear) return;
gear.addEventListener('click', (e) => {
e.stopPropagation();
const menu = document.getElementById('id_applet_menu');
if (!menu) return;
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', (e) => {
const menu = document.getElementById('id_applet_menu');
if (!menu || menu.style.display === 'none') return;
if (e.target.closest('#id_applet_menu_cancel') || !menu.contains(e.target)) {
menu.style.display = 'none';
}
});
}; };

View File

@@ -2,7 +2,7 @@ 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 Applet, Item, List, UserApplet
from apps.lyric.models import User from apps.lyric.models import User
@@ -43,7 +43,7 @@ class ItemModelTest(TestCase):
class ListModelTest(TestCase): class ListModelTest(TestCase):
def test_get_absolute_url(self): def test_get_absolute_url(self):
mylist = List.objects.create() mylist = List.objects.create()
self.assertEqual(mylist.get_absolute_url(), f"/apps/dashboard/{mylist.id}/") self.assertEqual(mylist.get_absolute_url(), f"/dashboard/list/{mylist.id}/")
def test_list_items_order(self): def test_list_items_order(self):
list1 = List.objects.create() list1 = List.objects.create()
@@ -68,3 +68,37 @@ class ListModelTest(TestCase):
Item.objects.create(list=list_, text="first item") Item.objects.create(list=list_, text="first item")
Item.objects.create(list=list_, text="second item") Item.objects.create(list=list_, text="second item")
self.assertEqual(list_.name, "first item") self.assertEqual(list_.name, "first item")
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)

View File

@@ -1,18 +1,24 @@
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.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 Applet, Item, List, UserApplet
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-list", defaults={"name": "New List"})
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,26 +27,30 @@ 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_list", [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_list"]
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 NewListTest(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_list", data={"text": "A new list 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 list 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_list", data={"text": "A new list item"})
new_list = List.objects.get() new_list = List.objects.get()
self.assertRedirects(response, f"/apps/dashboard/{new_list.id}/") self.assertRedirects(response, f"/dashboard/list/{new_list.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_list", 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()
@@ -58,12 +68,12 @@ class NewListTest(TestCase):
class ListViewTest(TestCase): class ListViewTest(TestCase):
def test_uses_list_template(self): def test_uses_list_template(self):
mylist = List.objects.create() mylist = List.objects.create()
response = self.client.get(f"/apps/dashboard/{mylist.id}/") response = self.client.get(f"/dashboard/list/{mylist.id}/")
self.assertTemplateUsed(response, "apps/dashboard/list.html") self.assertTemplateUsed(response, "apps/dashboard/list.html")
def test_renders_input_form(self): def test_renders_input_form(self):
mylist = List.objects.create() mylist = List.objects.create()
url = f"/apps/dashboard/{mylist.id}/" url = f"/dashboard/list/{mylist.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]")
@@ -80,7 +90,7 @@ class ListViewTest(TestCase):
other_list = List.objects.create() other_list = List.objects.create()
Item.objects.create(text="other list item", list=other_list) Item.objects.create(text="other list item", list=other_list)
# When/Act # When/Act
response = self.client.get(f"/apps/dashboard/{correct_list.id}/") response = self.client.get(f"/dashboard/list/{correct_list.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")
@@ -91,7 +101,7 @@ class ListViewTest(TestCase):
correct_list = List.objects.create() correct_list = List.objects.create()
self.client.post( self.client.post(
f"/apps/dashboard/{correct_list.id}/", f"/dashboard/list/{correct_list.id}/",
data={"text": "A new item for an existing list"}, data={"text": "A new item for an existing list"},
) )
@@ -105,16 +115,16 @@ class ListViewTest(TestCase):
correct_list = List.objects.create() correct_list = List.objects.create()
response = self.client.post( response = self.client.post(
f"/apps/dashboard/{correct_list.id}/", f"/dashboard/list/{correct_list.id}/",
data={"text": "A new item for an existing list"}, data={"text": "A new item for an existing list"},
) )
self.assertRedirects(response, f"/apps/dashboard/{correct_list.id}/") self.assertRedirects(response, f"/dashboard/list/{correct_list.id}/")
# Post invalid input helper # Post invalid input helper
def post_invalid_input(self): def post_invalid_input(self):
mylist = List.objects.create() mylist = List.objects.create()
return self.client.post(f"/apps/dashboard/{mylist.id}/", data={"text": ""}) return self.client.post(f"/dashboard/list/{mylist.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()
@@ -140,7 +150,7 @@ class ListViewTest(TestCase):
Item.objects.create(list=list1, text="lorem ipsum") Item.objects.create(list=list1, text="lorem ipsum")
response = self.client.post( response = self.client.post(
f"/apps/dashboard/{list1.id}/", f"/dashboard/list/{list1.id}/",
data={"text": "lorem ipsum"}, data={"text": "lorem ipsum"},
) )
@@ -153,26 +163,26 @@ class MyListsTest(TestCase):
def test_my_lists_url_renders_my_lists_template(self): def test_my_lists_url_renders_my_lists_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_lists.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_list_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_list", data={"text": "new item"})
new_list = List.objects.get() new_list = List.objects.get()
self.assertEqual(new_list.owner, user) self.assertEqual(new_list.owner, user)
def test_my_lists_redirects_if_not_logged_in(self): def test_my_lists_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_lists_returns_403_for_wrong_user(self):
@@ -180,7 +190,7 @@ class MyListsTest(TestCase):
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)
@@ -189,16 +199,16 @@ class ShareListTest(TestCase):
our_list = List.objects.create() our_list = List.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/list/{our_list.id}/share_list",
data={"recipient": "alice@example.com"}, data={"recipient": "alice@example.com"},
) )
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/") self.assertRedirects(response, f"/dashboard/list/{our_list.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_list = List.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/list/{our_list.id}/share_list",
data={"recipient": "alice@example.com"}, data={"recipient": "alice@example.com"},
) )
self.assertIn(alice, our_list.shared_with.all()) self.assertIn(alice, our_list.shared_with.all())
@@ -206,7 +216,201 @@ class ShareListTest(TestCase):
def test_post_with_nonexistent_email_redirects_to_list(self): def test_post_with_nonexistent_email_redirects_to_list(self):
our_list = List.objects.create() our_list = List.objects.create()
response = self.client.post( response = self.client.post(
f"/apps/dashboard/{our_list.id}/share_list", f"/dashboard/list/{our_list.id}/share_list",
data={"recipient": "nobody@example.com"}, data={"recipient": "nobody@example.com"},
) )
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/") self.assertRedirects(
response,
f"/dashboard/list/{our_list.id}/",
fetch_redirect_response=False,
)
def test_share_list_does_not_add_owner_as_recipient(self):
owner = User.objects.create(email="owner@example.com")
our_list = List.objects.create(owner=owner)
self.client.force_login(owner)
self.client.post(reverse("share_list", args=[our_list.id]),
data={"recipient": "owner@example.com"})
self.assertNotIn(owner, our_list.shared_with.all())
@override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage')
def test_share_list_shows_privacy_safe_message(self):
our_list = List.objects.create()
response = self.client.post(
f"/dashboard/list/{our_list.id}/share_list",
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 ViewAuthListTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="disco@example.com")
self.our_list = List.objects.create(owner=self.owner)
def test_anonymous_user_is_redirected(self):
response = self.client.get(reverse("view_list", args=[self.our_list.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_list", args=[self.our_list.id]))
self.assertEqual(response.status_code, 403)
def test_shared_with_user_can_access_list(self):
guest = User.objects.create(email="guest@example.com")
self.our_list.shared_with.add(guest)
self.client.force_login(guest)
response = self.client.get(reverse("view_list", args=[self.our_list.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_my_lists_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 ToggleAppletsViewTest(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)
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"])

View File

@@ -3,7 +3,10 @@ from . import views
urlpatterns = [ urlpatterns = [
path('new_list', views.new_list, name='new_list'), path('new_list', views.new_list, name='new_list'),
path('<int:list_id>/', views.view_list, name='view_list'), path('list/<uuid:list_id>/', views.view_list, name='view_list'),
path('list/<uuid: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_lists, name='my_lists'), path('users/<uuid:user_id>/', views.my_lists, name='my_lists'),
path('<int:list_id>/share_list', views.share_list, name="share_list"), path('toggle_applets', views.toggle_applets, name="toggle_applets"),
] ]

View File

@@ -1,11 +1,56 @@
from django.http import HttpResponseForbidden 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
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from .forms import ExistingListItemForm, ItemForm
from .models import Item, List from apps.dashboard.forms import ExistingListItemForm, ItemForm
from apps.dashboard.models import Applet, Item, List, UserApplet
from apps.lyric.models import User from apps.lyric.models import User
APPLET_ORDER = ["new-list", "my-lists", "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_lists(user, limit=3):
return (
List
.objects
.filter(Q(owner=user) | Q(shared_with=user))
.annotate(last_item=Max('item__id'))
.order_by('-last_item')
.distinct()[:limit]
)
def _applet_context(user):
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
applets = {a.slug: a for a in Applet.objects.all()}
return [
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
for slug in APPLET_ORDER
if slug in applets
]
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)
context["recent_lists"] = _recent_lists(request.user)
return render(request, "apps/dashboard/home.html", context)
def new_list(request): def new_list(request):
form = ItemForm(data=request.POST) form = ItemForm(data=request.POST)
@@ -17,10 +62,25 @@ def new_list(request):
form.save(for_list=nulist) form.save(for_list=nulist)
return redirect(nulist) return redirect(nulist)
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)
context["recent_lists"] = _recent_lists(request.user)
return render(request, "apps/dashboard/home.html", context)
def view_list(request, list_id): def view_list(request, list_id):
our_list = List.objects.get(id=list_id) our_list = List.objects.get(id=list_id)
if our_list.owner:
if not request.user.is_authenticated:
return redirect("/")
if request.user != our_list.owner and request.user not in our_list.shared_with.all():
return HttpResponseForbidden()
form = ExistingListItemForm(for_list=our_list) form = ExistingListItemForm(for_list=our_list)
if request.method == "POST": if request.method == "POST":
@@ -29,7 +89,7 @@ def view_list(request, list_id):
form.save() form.save()
return redirect(our_list) return redirect(our_list)
return render(request, "apps/dashboard/list.html", {"list": our_list, "form": form}) return render(request, "apps/dashboard/list.html", {"list": our_list, "form": form})
def my_lists(request, user_id): def my_lists(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:
@@ -42,7 +102,45 @@ def share_list(request, list_id):
our_list = List.objects.get(id=list_id) our_list = List.objects.get(id=list_id)
try: try:
recipient = User.objects.get(email=request.POST["recipient"]) recipient = User.objects.get(email=request.POST["recipient"])
if recipient == request.user:
return redirect(our_list)
our_list.shared_with.add(recipient) our_list.shared_with.add(recipient)
except User.DoesNotExist: except User.DoesNotExist:
pass pass
messages.success(request, "An invite has been sent if that address is registered.")
return redirect(our_list) return redirect(our_list)
@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.all():
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),
"palettes": PALETTES,
"form": ItemForm(),
"recent_lists": _recent_lists(request.user),
})
return redirect("home")

View File

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

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

@@ -24,6 +24,10 @@ class Token(models.Model):
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")
is_staff = models.BooleanField(default=False) is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False)

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

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

View File

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,16 @@ class UserModelTest(TestCase):
user = User(id="123") user = User(id="123")
self.assertEqual(user.pk, "123") self.assertEqual(user.pk, "123")
def test_user_can_have_a_username(self):
user = User.objects.create(email="a@b.cde")
user.username = "stardust"
user.save()
self.assertEqual(User.objects.get(pk=user.pk).username, "stardust")
def test_searchable_defaults_to_false(self):
user = User.objects.create(email="a@b.cde")
self.assertFalse(user.searchable)
class TokenModelTest(TestCase): class TokenModelTest(TestCase):
def test_links_user_with_autogen_uid(self): def test_links_user_with_autogen_uid(self):
token1 = Token.objects.create(email="a@b.cde") token1 = Token.objects.create(email="a@b.cde")
@@ -40,3 +50,8 @@ class UserManagerTest(TestCase):
password="correct-password", password="correct-password",
) )
self.assertTrue(user.check_password("correct-password")) self.assertTrue(user.check_password("correct-password"))
class UserPaletteTest(TestCase):
def test_palette_field_defaults_to_palette_default(self):
user = User.objects.create(email="a@b.cde")
self.assertEqual(user.palette, "palette-default")

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -11,8 +11,11 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
from pathlib import Path from pathlib import Path
import os
import dj_database_url import dj_database_url
import os
import sys
if 'test' in sys.argv:
COMPRESS_ENABLED = False
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -34,6 +37,7 @@ if 'DJANGO_DEBUG_FALSE' in os.environ:
SECURE_HSTS_SECONDS = 60 SECURE_HSTS_SECONDS = 60
SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True SECURE_HSTS_PRELOAD = True
COMPRESS_OFFLINE = True
else: else:
DEBUG = True DEBUG = True
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
@@ -54,8 +58,11 @@ INSTALLED_APPS = [
# Custom apps # Custom apps
'apps.dashboard', 'apps.dashboard',
'apps.lyric', 'apps.lyric',
'apps.api',
'functional_tests', 'functional_tests',
# Depend apps # Depend apps
'compressor',
'rest_framework',
] ]
# if 'DJANGO_DEBUG_FALSE' not in os.environ: # if 'DJANGO_DEBUG_FALSE' not in os.environ:
@@ -70,6 +77,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_htmx.middleware.HtmxMiddleware',
] ]
ROOT_URLCONF = 'core.urls' ROOT_URLCONF = 'core.urls'
@@ -84,6 +92,7 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'core.context_processors.user_palette',
], ],
}, },
}, },
@@ -107,6 +116,17 @@ else:
} }
} }
# Celery & Redis
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
REDIS_URL = os.environ.get('REDIS_URL')
if REDIS_URL:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': REDIS_URL,
}
}
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -154,6 +174,14 @@ STATIC_ROOT = BASE_DIR / 'static'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'static_src', BASE_DIR / 'static_src',
] ]
STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
'compressor.finders.CompressorFinder',
]
COMPRESS_PRECOMPILERS = [
('text/x-scss', 'django_libsass.SassCompiler'),
]
LOGGING = { LOGGING = {
"version": 1, "version": 1,

View File

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

View File

@@ -12,6 +12,7 @@ from selenium.webdriver.common.keys import Keys
from .container_commands import create_session_on_server, reset_database from .container_commands import create_session_on_server, reset_database
from .management.commands.create_session import create_pre_authenticated_session from .management.commands.create_session import create_pre_authenticated_session
from apps.dashboard.models import Applet
@@ -44,6 +45,7 @@ class FunctionalTest(StaticLiveServerTestCase):
if self.test_server: if self.test_server:
self.live_server_url = 'http://' + self.test_server self.live_server_url = 'http://' + self.test_server
reset_database(self.test_server) reset_database(self.test_server)
Applet.objects.get_or_create(slug="new-list", defaults={"name": "New List"})
def tearDown(self): def tearDown(self):
if self._test_has_failed(): if self._test_has_failed():
@@ -65,7 +67,7 @@ class FunctionalTest(StaticLiveServerTestCase):
def dump_html(self): def dump_html(self):
path = SCREEN_DUMP_LOCATION / self._get_filename("html") path = SCREEN_DUMP_LOCATION / self._get_filename("html")
print("dumping page html to", path) print("dumping page html to", path)
path.write_text(self.browser.page_source) path.write_text(self.browser.page_source, encoding="utf-8")
def _get_filename(self, extension): def _get_filename(self, extension):
timestamp = datetime.now().isoformat().replace(":", ".") timestamp = datetime.now().isoformat().replace(":", ".")

View File

@@ -1,5 +1,7 @@
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from apps.lyric.models import User
class MyListsPage: class MyListsPage:
def __init__(self, test): def __init__(self, test):
@@ -7,7 +9,10 @@ class MyListsPage:
def go_to_my_lists_page(self, email): def go_to_my_lists_page(self, email):
self.test.browser.get(self.test.live_server_url) self.test.browser.get(self.test.live_server_url)
self.test.browser.find_element(By.LINK_TEXT, "My lists").click() user = User.objects.get(email=email)
self.test.browser.get(
self.test.live_server_url + f'/dashboard/users/{user.id}/'
)
self.test.wait_for( self.test.wait_for(
lambda: self.test.assertIn( lambda: self.test.assertIn(
email, email,

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ from .list_page import ListPage
class LayoutAndStylingTest(FunctionalTest): class LayoutAndStylingTest(FunctionalTest):
def test_layout_and_styling(self): def test_layout_and_styling(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self) list_page = ListPage(self)

View File

@@ -12,6 +12,7 @@ class ItemValidationTest(FunctionalTest):
# Test methods # Test methods
def test_cannot_add_empty_list_items(self): def test_cannot_add_empty_list_items(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self) list_page = ListPage(self)
list_page.get_item_input_box().send_keys(Keys.ENTER) list_page.get_item_input_box().send_keys(Keys.ENTER)
@@ -46,6 +47,7 @@ class ItemValidationTest(FunctionalTest):
list_page.wait_for_row_in_list_table("Make tea", 2) list_page.wait_for_row_in_list_table("Make tea", 2)
def test_cannot_add_duplicate_items(self): def test_cannot_add_duplicate_items(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self) list_page = ListPage(self)
list_page.add_list_item("Witness divinity") list_page.add_list_item("Witness divinity")
@@ -61,6 +63,7 @@ class ItemValidationTest(FunctionalTest):
) )
def test_error_messages_are_cleared_on_input(self): def test_error_messages_are_cleared_on_input(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self) list_page = ListPage(self)
list_page.add_list_item("Gobbledygook") list_page.add_list_item("Gobbledygook")

View File

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

View File

@@ -3,12 +3,13 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from .list_page import ListPage from .list_page import ListPage
from .my_lists_page import MyListsPage from .my_lists_page import MyListsPage
from apps.lyric.models import User
class MyListsTest(FunctionalTest): class MyListsTest(FunctionalTest):
def test_logged_in_users_lists_are_saved_as_my_lists(self): def test_logged_in_users_lists_are_saved_as_my_lists(self):
self.create_pre_authenticated_session("discoman@example.com") self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self) list_page = ListPage(self)
@@ -16,7 +17,7 @@ class MyListsTest(FunctionalTest):
list_page.add_list_item("Regurgitate spines") list_page.add_list_item("Regurgitate spines")
first_list_url = self.browser.current_url first_list_url = self.browser.current_url
MyListsPage(self).go_to_my_lists_page("discoman@example.com") MyListsPage(self).go_to_my_lists_page("disco@test.io")
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines") lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines")
@@ -30,11 +31,10 @@ class MyListsTest(FunctionalTest):
list_page.add_list_item("Ribbon of death") list_page.add_list_item("Ribbon of death")
second_list_url = self.browser.current_url second_list_url = self.browser.current_url
self.browser.find_element(By.LINK_TEXT, "My lists").click() MyListsPage(self).go_to_my_lists_page("disco@test.io")
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death") lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death")
) )
MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click() self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click()
self.wait_for( self.wait_for(

View File

@@ -1,5 +1,6 @@
import os import os
from django.conf import settings
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@@ -19,7 +20,7 @@ def quit_if_possible(browser):
# Test mdls # Test mdls
class SharingTest(FunctionalTest): class SharingTest(FunctionalTest):
def test_can_share_a_list_with_another_user(self): def test_can_share_a_list_with_another_user(self):
self.create_pre_authenticated_session("discoman@example.com") self.create_pre_authenticated_session("disco@test.io")
disco_browser = self.browser disco_browser = self.browser
self.addCleanup(lambda: quit_if_possible(disco_browser)) self.addCleanup(lambda: quit_if_possible(disco_browser))
@@ -29,7 +30,7 @@ class SharingTest(FunctionalTest):
ali_browser = webdriver.Firefox(options=options) ali_browser = webdriver.Firefox(options=options)
self.addCleanup(lambda: quit_if_possible(ali_browser)) self.addCleanup(lambda: quit_if_possible(ali_browser))
self.browser = ali_browser self.browser = ali_browser
self.create_pre_authenticated_session("alice@example.com") self.create_pre_authenticated_session("alice@test.io")
self.browser = disco_browser self.browser = disco_browser
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
@@ -41,15 +42,15 @@ class SharingTest(FunctionalTest):
"friend@example.com", "friend@example.com",
) )
list_page.share_list_with("alice@example.com") list_page.share_list_with("alice@test.io")
self.browser = ali_browser self.browser = ali_browser
MyListsPage(self).go_to_my_lists_page("alice@example.com") MyListsPage(self).go_to_my_lists_page("alice@test.io")
self.browser.find_element(By.LINK_TEXT, "Send help").click() self.browser.find_element(By.LINK_TEXT, "Send help").click()
self.wait_for( self.wait_for(
lambda: self.assertEqual(list_page.get_list_owner(), "discoman@example.com") lambda: self.assertEqual(list_page.get_list_owner(), "disco@test.io")
) )
list_page.add_list_item("At your command, Disco King") list_page.add_list_item("At your command, Disco King")
@@ -57,3 +58,16 @@ class SharingTest(FunctionalTest):
self.browser = disco_browser self.browser = disco_browser
self.browser.refresh() self.browser.refresh()
list_page.wait_for_row_in_list_table("At your command, Disco King", 2) list_page.wait_for_row_in_list_table("At your command, Disco King", 2)
class ListAccessTest(FunctionalTest):
def test_stranger_cannot_access_owned_list(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url)
list_page = ListPage(self).add_list_item("private eye")
list_url = self.browser.current_url
self.browser.delete_cookie(settings.SESSION_COOKIE_NAME)
self.browser.get(list_url)
self.assertNotEqual(self.browser.current_url, list_url)

View File

@@ -8,6 +8,7 @@ from .list_page import ListPage
class NewVisitorTest(FunctionalTest): class NewVisitorTest(FunctionalTest):
# Test methods # Test methods
def test_can_start_a_todo_list(self): def test_can_start_a_todo_list(self):
self.create_pre_authenticated_session("alice@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self) list_page = ListPage(self)
@@ -29,15 +30,20 @@ class NewVisitorTest(FunctionalTest):
list_page.wait_for_row_in_list_table("Buy peacock feathers", 1) list_page.wait_for_row_in_list_table("Buy peacock feathers", 1)
def test_multiple_users_can_start_lists_at_different_urls(self): def test_multiple_users_can_start_lists_at_different_urls(self):
self.create_pre_authenticated_session("alice@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self) list_page = ListPage(self)
list_page.add_list_item("Buy peacock feathers") list_page.add_list_item("Buy peacock feathers")
edith_dash_url = self.browser.current_url edith_dash_url = self.browser.current_url
self.assertRegex(edith_dash_url, '/apps/dashboard/.+') self.assertRegex(
edith_dash_url,
r'/dashboard/list/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
)
self.browser.delete_all_cookies() self.browser.delete_all_cookies()
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self) list_page = ListPage(self)
page_text = self.browser.find_element(By.TAG_NAME, 'body').text page_text = self.browser.find_element(By.TAG_NAME, 'body').text
@@ -46,7 +52,10 @@ class NewVisitorTest(FunctionalTest):
list_page.add_list_item("Buy milk") list_page.add_list_item("Buy milk")
francis_dash_url = self.browser.current_url francis_dash_url = self.browser.current_url
self.assertRegex(francis_dash_url, '/apps/dashboard/.+') self.assertRegex(
francis_dash_url,
r'/dashboard/list/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
)
self.assertNotEqual(francis_dash_url, edith_dash_url) self.assertNotEqual(francis_dash_url, edith_dash_url)
page_text = self.browser.find_element(By.TAG_NAME, 'body').text page_text = self.browser.find_element(By.TAG_NAME, 'body').text

View File

@@ -0,0 +1,166 @@
body {
display: flex;
flex-direction: column;
background-color: rgba(var(--priUser), 1);
color: rgba(var(--secUser), 1);
font-family: Georgia, serif;
height: 100vh;
a {
text-decoration: none;
font-weight: 700;
color: rgba(var(--terUser), 1);
&:hover {
color: rgba(var(--ninUser), 1);
text-shadow: 0 0 0.5rem rgba(var(--terUser), 1);
}
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 1rem;
.navbar {
padding: 0.75rem 0;
border-bottom: 0.1rem solid rgba(var(--secUser), 0.4);
.navbar-brand {
h1 {
font-size: 2rem;
}
}
.container-fluid {
display: flex;
align-items: center;
gap: 1rem;
> form { flex-shrink: 0; margin-left: auto; }
}
.navbar-text,
.navbar-link {
flex: 1;
min-width: 0;
text-align: center;
.navbar-label {
display: block;
color: rgba(var(--secUser), 0.7);
font-size: 0.75rem;
}
.navbar-identity {
display: block;
color: rgba(var(--quaUser), 1);
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.input-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
.form-control {
width: auto;
}
}
.form-control {
background-color: rgba(var(--priUser), 1);
color: rgba(var(--secUser), 1);
border: 0.1rem solid rgba(var(--secUser), 0.5);
--_pad-v: 0.5rem;
padding: var(--_pad-v) 0.75rem;
border-radius: calc((var(--_pad-v) * 2 + 1em) / 3);
width: 100%;
font-family: inherit;
&.is-invalid {
border-color: rgba(var(--priRd), 1);
}
&.form-control-lg {
--_pad-v: 0.75rem;
padding: var(--_pad-v) 1rem;
font-size: 1.125rem;
}
&.is-invalid ~ .invalid-feedback {
display: block;
}
&:focus {
border-color: rgba(var(--terUser), 0.75);
box-shadow: 0 0 0.75rem rgba(var(--terUser), 0.5);
}
}
.invalid-feedback {
display: none;
color: rgba(var(--priRd), 1);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.alert {
padding: 0.75rem 1rem;
margin: 0.75rem 0;
border-radius: 0.5rem;
border: 0.1rem solid rgba(var(--priYl), 0.5);
color: rgba(var(--priYl), 1);
&.alert-success {
border-color: rgba(var(--priGn), 0.5);
color: rgba(var(--priGn), 1);
}
&.alert-warning {
border-color: rgba(var(--priOr), 0.5);
color: rgba(var(--priOr), 1);
}
}
.row {
padding: 2rem 0;
.col-md-12 {
width: 100%;
justify-content: center;
}
.col-lg-6 {
max-width: 600px;
margin: 0 auto;
h2 {
font-size: 2.5rem;
color: rgba(var(--quaUser), 1);
margin-bottom: 1rem;
}
}
}
}
}
#id_footer {
flex-shrink: 0;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
border-top: 0.1rem solid rgba(var(--secUser), 0.3);
background: linear-gradient(
to top,
rgba(var(--priUser), 1) 25%,
transparent 100%
);
}

View File

@@ -0,0 +1,304 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
width: 2rem;
height: 2rem;
text-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.5);
border: 0.15rem solid rgba(var(--priUser), 1);
border-radius: 50%;
font-weight: 700;
font-size: 0.63rem;
text-transform: uppercase;
margin: 0.25rem;
flex-shrink: 0;
&:hover,
&:active {
cursor: pointer;
}
&:active {
font-size: 0.61rem;
border: 0.18rem solid rgba(var(--priUser), 1);
}
&.btn-primary {
color: rgba(var(--quaUser), 1);
border-color: rgba(var(--quaUser), 1);
background-color: rgba(var(--quiUser), 1);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--quiUser), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--quiUser), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--quaUser), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--quaUser), 0.12)
;
}
&:active {
border: 0.18rem solid rgba(var(--quaUser), 1);
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--quaUser), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--quiUser), 0.25),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--quaUser), 0.12)
;
}
}
&.btn-xl {
width: 4rem;
height: 4rem;
font-size: 0.875rem;
border-width: 0.21rem;
&:hover {
text-shadow:
0.2rem 0.2rem 0.2rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--quaUser), 1)
;
box-shadow:
0.24rem 0.24rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--quaUser), 22)
;
}
&:active {
border-width: 0.25rem;
text-shadow:
-0.2rem -0.2rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.24rem rgba(var(--quaUser), 1)
;
box-shadow:
-0.2rem -0.2rem 0.24rem rgba(var(--quiUser), 0.25),
-0.2rem -0.2rem 0.24rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--quaUser), 0.22)
;
}
}
&.btn-cancel {
color: rgba(var(--priOr), 1);
border-color: rgba(var(--priOr), 1);
background-color: rgba(var(--terOr), 1);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terOr), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terOr), 0.12)
;
&:hover {
text-shadow:
0.2rem 0.2rem 0.2rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priOr), 1)
;
box-shadow:
0.24rem 0.24rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priOr), 0.12)
;
}
&:active {
text-shadow:
-0.1rem -0.1rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priOr), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--terOr), 0.25),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priOr), 0.12)
;
}
}
&.btn-caution {
color: rgba(var(--priYl), 1);
border-color: rgba(var(--priYl), 1);
background-color: rgba(var(--terYl), 1);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terYl), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terYl), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--priYl), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priYl), 0.12)
;
}
&:active {
border: 0.18rem solid rgba(var(--priYl), 1);
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priYl), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--terYl), 0.25),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priYl), 0.12)
;
}
}
&.btn-confirm {
color: rgba(var(--priGn), 1);
border-color: rgba(var(--priGn), 1);
background-color: rgba(var(--terGn), 1);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terGn), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terGn), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--priGn), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priGn), 0.12)
;
}
&:active {
border: 0.18rem solid rgba(var(--priGn), 1);
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priGn), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--terGn), 0.25),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priGn), 0.12)
;
}
}
&.btn-danger {
color: rgba(var(--priRd), 1);
background-color: rgba(var(--terRd), 1);
border-color: rgba(var(--priRd), 1);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terRd), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terRd), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--priRd), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priRd), 0.12)
;
}
&:active {
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priRd), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--terRd), 0.25),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priRd), 0.12)
;
}
&.stop-player {
width: 4rem;
height: 4rem;
font-size: 0.9rem;
border: 0.2rem solid rgba(var(--priRd), 1);
box-shadow:
0.1rem 0.1rem 0.25rem rgba(var(--terRd), 0.5),
0.25rem 0.25rem 1rem rgba(0, 0, 0, 0.5),
0.5rem 0.5rem 0.5rem rgba(var(--terRd), 0.25)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.5),
0 0 1rem rgba(var(--priRd), 1)
;
box-shadow:
0.25rem 0.25rem 1rem rgba(0, 0, 0, 0.5),
0 0 1rem rgba(var(--priRd), 0.25)
;
}
&:active {
font-size: 0.88rem;
border: 0.25rem solid rgba(var(--priRd), 1);
text-shadow:
-0.1rem -0.1rem 0.5rem rgba(0, 0, 0, 0.5),
0 0 0.25rem rgba(var(--priRd), 1)
;
box-shadow:
-0.1rem -0.1rem 0.25rem rgba(var(--terRd), 0.5),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.5),
0 0 1rem rgba(var(--priRd), 0.25)
;
}
}
}
&.btn-disabled {
cursor: default !important;
font-size: 1.2rem;
padding-bottom: 0.1rem;
color: rgba(var(--secUser), 0.25);
background-color: rgba(var(--priUser), 1);
border-color: rgba(var(--secUser), 0.25);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--secUser), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--priUser), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priUser), 0.12)
;
}
&:active {
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priUser), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--priUser), 0.75),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--secUser), 0.12)
;
}
}
}

View File

@@ -0,0 +1,233 @@
html:has(body.page-dashboard) {
overflow: hidden;
}
body.page-dashboard {
overflow: hidden;
.container {
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.row {
flex-shrink: 0;
margin-bottom: -1rem;
}
}
#id_dash_content {
flex: 1;
min-width: 425px;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
#id_dash_gear {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
padding-bottom: 0.2rem;
z-index: 1;
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: rgba(var(--secUser), 1);
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 1);
}
#id_applet_menu {
position: absolute;
bottom: 3rem;
right: 0.5rem;
z-index: 100;
background-color: rgba(var(--priUser), 0.95);
border: 0.15rem solid rgba(var(--secUser), 0.5);
border-radius: 0.75rem;
padding: 1rem;
.menu-btns {
display: flex;
gap: 0.25rem;
margin-top: 0.75rem;
}
}
#id_applets_container {
container-type: inline-size;
--grid-gap: 0.5rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-rows: 3rem;
gap: var(--grid-gap);
padding: 0.75rem;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 2%,
black 98%,
transparent 100%
);
section {
border: 0.2rem solid rgba(var(--secUser), 0.5);
border-radius: 0.75rem;
padding: 1rem;
overflow: hidden;
min-width: 0;
grid-column: span var(--applet-cols, 12);
grid-row: span var(--applet-rows, 3);
}
#id_applet_my_lists {
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
.my-lists-main {
font-size: 1.6rem;
}
.my-lists-container {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
mask-origin: padding-box;
mask-clip: padding-box;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 5%,
black 85%,
transparent 100%
);
}
}
#id_applet_palette {
padding: 0;
.palette-scroll {
display: flex;
gap: 3.5rem;
overflow-x: auto;
padding: 0.75rem 2rem;
height: 100%;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
mask-image: linear-gradient(
to right,
transparent 0%,
black 2%,
black 98%,
transparent 100%
);
}
}
#id_applet_username {
display: flex;
align-items: center;
overflow: hidden;
form {
min-width: 0;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
.save-btn {
align-self: left;
}
}
.username-field {
display: flex;
align-items: baseline;
gap: 0.1em;
min-width: 0;
overflow: hidden;
.username-at{
user-select: none;
pointer-events: none;
font-size: 1.8rem;
font-weight: bold;
color: rgba(var(--secUser), 0.875);
margin-left: 0.3rem;
}
input {
background: transparent;
border: none;
outline: none;
font-size: 1.8rem;
font-weight: bold;
color: rgba(var(--secUser), 0.875);;
font-family: inherit;
padding: 0;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
@media (max-width: 550px) {
#id_dash_content {
min-width: 0;
overflow: hidden;
}
#id_applets_container {
section {
grid-column: span 12;
}
}
}
@media (min-width: 738px) {
#id_dash_content {
min-width: 666px;
overflow: hidden;
}
}
@media (max-height: 500px) {
body.page-dashboard {
.container {
.row {
padding: 0.25rem 0;
.col-lg-6 h2 {
margin-bottom: 0.5rem;
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
.palette {
display: flex;
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
gap: 0.75rem;
padding-bottom: 0.5rem;
}
.palette-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
flex: 0 0 auto;
height: 100%;
scroll-snap-align: start;
}
.swatch {
flex: 1;
min-height: 0;
aspect-ratio: 1;
border-radius: 0.5rem;
background: linear-gradient(
to bottom,
rgba(var(--terUser), 1) 0%,
rgba(var(--terUser), 1) 33%,
rgba(var(--priUser), 1) 33%,
rgba(var(--priUser), 1) 66%,
rgba(var(--quiUser), 1) 66%,
rgba(var(--quiUser), 1) 100%
);
border: 0.15rem solid rgba(var(--secUser), 0.5);
&.active {
border: 0.2rem solid rgba(var(--ninUser), 1);
box-shadow: 0 0 0.5rem rgba(var(--ninUser), 0.5);
}
&.locked {
opacity: 0.5;
filter: saturate(0.4);
}
}

View File

@@ -0,0 +1,14 @@
@import 'rootvars';
@import 'base';
@import 'button-pad';
@import 'dashboard';
@import 'palette-picker';
input,
textarea,
select,
[contenteditable] {
user-select: text;
touch-action: auto;
}

View File

@@ -0,0 +1,473 @@
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
outline: none;
}
:root {
/* rgb Variable Index */
/* Precious Metal Palette */
// nickel
--priNi: 141, 142, 140;
--secNi: 118, 120, 118;
--terNi: 93, 95, 94;
--terNi: 0, 0, 0;
--quaNi: 0, 0, 0;
--quiNi: 0, 0, 0;
--sixNi: 0, 0, 0;
// palladium
--priPd: 188, 193, 165;
--secPd: 155, 160, 138;
--terPd: 124, 129, 111;
--quaPd: 0, 0, 0;
--quiPd: 0, 0, 0;
--sixPd: 0, 0, 0;
// platinum
--priPt: 229, 228, 226;
--secPt: 189, 190, 189;
--terPt: 152, 153, 153;
--quaPt: 0, 0, 0;
--quiPt: 0, 0, 0;
--sixPt: 0, 0, 0;
// titanium
--priTi: 38, 57, 69;
--secTi: 57, 79, 94;
--terTi: 75, 101, 119;
--quaTi: 91, 121, 142;
--quiTi: 124, 166, 191;
--sixTi: 159, 200, 224;
// gold (Sun)
--priAu: 61, 48, 2;
--secAu: 99, 80, 8;
--terAu: 148, 121, 24;
--quaAu: 181, 154, 54;
--quiAu: 214, 186, 84;
--sixAu: 237, 214, 130;
// silver (Moon)
--priAg: 30, 30, 30;
--secAg: 60, 60, 60;
--terAg: 100, 100, 100;
--quaAg: 133, 133, 133;
--quiAg: 175, 175, 175;
--sixAg: 240, 240, 240;
/* Cosmic Metal Palette */
// mercury (Mercury)
--priHg: 23, 31, 51;
--secHg: 51, 62, 87;
--terHg: 87, 98, 128;
--quaHg: 139, 148, 176;
--quiHg: 176, 186, 209;
--sixHg: 206, 214, 237;
// copper (Venus)
--priCu: 46, 24, 5;
--secCu: 84, 48, 17;
--terCu: 133, 81, 36;
--quaCu: 171, 112, 60;
--quiCu: 207, 173, 143;
--sixCu: 242, 216, 191;
// iron (Mars) ***n.b.!—ancient 'iron' was actually meteoric iron, an iron-nickel alloy like kamacite (weaponry) or taenite (decor)
--priFe: 51, 47, 26;
--secFe: 74, 72, 45;
--terFe: 105, 103, 74;
--quaFe: 148, 144, 115;
--quiFe: 184, 178, 154;
--sixFe: 224, 219, 202;
// tin (Jupiter)
--priSn: 36, 36, 19;
--secSn: 68, 72, 42;
--terSn: 100, 102, 66;
--quaSn: 148, 150, 110;
--quiSn: 207, 209, 180;
--sixSn: 243, 245, 223;
// lead (Saturn)
--priPb: 33, 40, 46;
--secPb: 48, 59, 64;
--terPb: 87, 102, 107;
--quaPb: 126, 142, 148;
--quiPb: 163, 180, 184;
--sixPb: 213, 228, 232;
// uranium (Uranus)
--priU: 21, 39, 18;
--secU: 32, 59, 41;
--terU: 85, 129, 69;
--quaU: 114, 156, 100;
--quiU: 167, 196, 149;
--sixU: 209, 240, 192;
// neptunium (Neptune)
--priNp: 16, 59, 49;
--secNp: 37, 84, 76;
--terNp: 85, 135, 129;
--quaNp: 107, 156, 148;
--quiNp: 139, 181, 175;
--sixNp: 197, 227, 224;
// plutonium (Pluto)
--priPu: 29, 18, 38;
--secPu: 59, 44, 71;
--terPu: 84, 71, 97;
--quaPu: 109, 98, 128;
--quiPu: 169, 155, 194;
--sixPu: 235, 211, 217;
/* Chroma Palette */
// red
--priRd: 233, 53, 37;
--secRd: 193, 43, 28;
--terRd: 155, 31, 15;
// orange
--priOr: 225, 133, 40;
--secOr: 187, 111, 30;
--terOr: 150, 88, 17;
// yellow
--priYl: 255, 207, 52;
--secYl: 211, 172, 44;
--terYl: 168, 138, 33;
// lime
--priLm: 151, 174, 60;
--secLm: 124, 145, 48;
--terLm: 97, 117, 36;
// green
--priGn: 0, 160, 75;
--secGn: 0, 135, 62;
--terGn: 0, 109, 48;
// teal
--priTk: 0, 184, 162;
--secTk: 0, 154, 136;
--terTk: 0, 125, 110;
// cyan
--priCy: 13, 179, 200;
--secCy: 12, 150, 168;
--terCy: 0, 121, 136;
// blue
--priBl: 20, 141, 205;
--secBl: 18, 119, 173;
--terBl: 8, 95, 140;
// indigo
--priId: 79, 102, 212;
--secId: 66, 88, 184;
--terId: 53, 74, 156;
--quaId: 44, 60, 131;
--quiId: 32, 44, 106;
--sixId: 21, 29, 71;
// violet
--priVt: 120, 72, 183;
--secVt: 108, 65, 165;
--terVt: 96, 58, 147;
--quaVt: 80, 45, 124;
--quiVt: 64, 30, 100;
--sixVt: 43, 20, 66;
// fuschia
--priFs: 158, 61, 150;
--secFs: 133, 47, 126;
--terFs: 107, 31, 101;
// magenta
--priMe: 237, 30, 129;
--secMe: 196, 18, 108;
--terMe: 158, 1, 86;
/* Earthman Palette */
// bark
--priBrk: 182, 103, 98;
--secBrk: 132, 78, 68;
--terBrk: 82, 53, 38;
// khaki
--priKhk: 195, 176, 145;
--secKhk: 145, 126, 95;
--terKhk: 95, 76, 45;
// cotton
--priCtn: 255, 251, 246;
--secCtn: 205, 201, 196;
--terCtn: 155, 151, 146;
// maize
--priMze: 242, 200, 63;
--secMze: 192, 151, 42;
--terMze: 142, 101, 21;
// cornflower
--priCfw: 100, 149, 237;
--secCfw: 67, 99, 187;
--terCfw: 33, 49, 137;
// purple mountain's majesty
--priPmm: 189, 170, 209;
--secPmm: 150, 120, 182;
--terPmm: 112, 79, 146;
// forest
--priFor: 190, 209, 170;
--secFor: 152, 182, 120;
--terFor: 114, 146, 79;
/* Technoman Palette */
// carbon steel
// stainless steel
// maraging steel
// silicon semiconductor
// wrought iron
// carbon fiber
// glass (optic)
/* Other H. sapiens variants */
// glass (frosted)
// glass (borosilicate)
// quartz
// iron (meteoric)
/* Inferno Palette (4 per) */
// mist (Elpis's Lethe)
--priMst: 168, 202, 172;
--secMst: 103, 145, 105;
--terMst: 90, 129, 198;
--quaMst: 13, 71, 47;
// tears (Ananke's Acheron)
--priTrs: 212, 221, 190;
--secTrs: 161, 208, 202;
--terTrs: 81, 153, 139;
--quaTrs: 47, 89, 85;
// swamp (Eros's Styx)
--priSwp: 221, 206, 149;
--secSwp: 148, 150, 103;
--terSwp: 102, 92, 67;
--quaSwp: 43, 46, 37;
// blood (Tyche's Phlegethon)
--priBld: 190, 69, 40;
--secBld: 167, 53, 42;
--terBld: 120, 37, 33;
--quaBld: 77, 23, 13;
// ice (Daimon's Cocytus)
--priIce: 165, 190, 187;
--secIce: 121, 150, 156;
--terIce: 74, 119, 125;
--quaIce: 35, 65, 75;
/* Terrestre Palette (6 per) */
// crumbling perse (Contrition)
--priPer: 34, 30, 77;
--secPer: 52, 45, 99;
--terPer: 88, 77, 145;
--quaPer: 127, 116, 194;
--quiPer: 164, 160, 222;
--sixPer: 206, 201, 242;
// polished marble (Confession)
--priMrb: 231, 233, 234;
--secMrb: 115, 116, 117;
// flaming porphyry (Satisfaction)
--priPhy: 200, 55, 66;
--secPhy: 75, 31, 48;
// threshold of adamant (Absolution)
--priAdm: 35, 40, 43;
--secAdm: 75, 81, 84;
--terAdm: 119, 131, 135;
--quaAdm: 164, 180, 186;
--quiAdm: 197, 213, 228;
--sixAdm: 226, 244, 253;
/* Emanation Palettes */
// Plant Bundle
// • beige-pink (streetlamps)
--priBpk: 223, 159, 140;
// • pale-yellow (poisonous)
--priBpk: 255, 235, 169;
// • bright violet (medicinal)
--priBpk: 223, 64, 196;
// • white, murky (power)
--priBpk: 196, 180, 193;
// • white, brilliant (power)
--sixBpk: 250, 246, 249;
// Insect Bundle
// • buff peach (neon lights)
--priBfp: 255, 92, 43;
// Animal Bundle
// • amber (clear honey)
--priClh: 238, 160, 70;
--secClh: 255, 216, 171;
// • pink (common)
--terClh: 238, 70, 148;
--quaClh: 96, 5, 57;
// • pale green (common)
--quiClh: 120, 203, 53;
--sixClh: 220, 255, 125;
// • blue (unusual)
--sepClh: 56, 84, 173;
--octClh: 26, 51, 105;
// • pure (rare)
--ninClh: 192, 77, 1;
--decClh: 255, 174, 0;
}
/* Default Earthman Palette */
.palette-default {
--priUser: var(--terBrk);
--secUser: var(--priKhk);
--terUser: var(--priMze);
--quaUser: var(--priPmm);
--quiUser: var(--terPmm);
--sixUser: var(--priFor);
--sepUser: var(--terFor);
--octUser: var(--priCfw);
--ninUser: var(--priCtn);
--decUser: var(--terCtn);
}
/* Grave Sheol Palette */
.palette-sheol {
--priUser: var(--priPu);
--secUser: var(--quiPu);
--terUser: var(--terFs);
--quaUser: var(--priCfw);
--quiUser: var(--terCfw);
--sixUser: var(--terId);
--sepUser: var(--secId);
--octUser: var(--priFs);
--ninUser: var(--sixPu);
--decUser: var(--terPu);
}
/* Blissful Nirvana Palette */
.palette-nirvana {
--priUser: var(--priU);
--secUser: var(--quiU);
--terUser: var(--terMe);
--quaUser: var(--quiCu);
--quiUser: var(--terCu);
--sixUser: var(--terKhk);
--sepUser: var(--priKhk);
--octUser: var(--priMe);
--ninUser: var(--sixCu);
--decUser: var(--terU);
}
/* Disco Inferno Palette */
.palette-inferno {
--priUser: var(--quaSwp);
--secUser: var(--priSwp);
--terUser: var(--terBld);
--quaUser: var(--priIce);
--quiUser: var(--quaIce);
--sixUser: var(--priTrs);
--sepUser: var(--terTrs);
--octUser: var(--priBld);
--ninUser: var(--priMst);
--decUser: var(--terMst);
}
/* Torre Terrestre Palette */
.palette-terrestre {
--priUser: var(--priAdm);
--secUser: var(--quaAdm);
--terUser: var(--sixAdm);
--quaUser: var(--priPhy);
--quiUser: var(--secPhy);
--sixUser: var(--priMrb);
--sepUser: var(--terPer);
--octUser: var(--quaAdm);
--ninUser: var(--sixPer);
--decUser: var(--terMrb);
}
/* Fantastia Celestia Palette */
.palette-celestia {
--priUser: var(--octClh);
--secUser: var(--sixClh);
--terUser: var(--quaClh);
--quaUser: var(--decClh);
--quiUser: var(--ninClh);
--sixUser: var(--sepClh);
--sepUser: var(--secClh);
--octUser: var(--terClh);
--ninUser: var(--priClh);
--decUser: var(--quiClh);
}
/* Palette Classes */
.priUser {
color: rgba(var(--priUser), 1);
}
.priUser-bg {
background-color: rgba(var(--priUser), 1);
}
.priUser-bd {
border-color: rgba(var(--priUser), 1);
}
.secUser {
color: rgba(var(--secUser), 1);
}
.secUser-bg {
background-color: rgba(var(--secUser), 1);
}
.secUser-bd {
border-color: rgba(var(--secUser), 1);
}
.terUser {
color: rgba(var(--terUser), 1);
}
.terUser-bg {
background-color: rgba(var(--terUser), 1);
}
.terUser-bd {
border-color: rgba(var(--terUser), 1);
}
.quaUser {
color: rgba(var(--quaUser), 1);
}
.quaUser-bg {
background-color: rgba(var(--quaUser), 1);
}
.quaUser-bd {
border-color: rgba(var(--secUser), 1);
}
.quiUser {
color: rgba(var(--quiUser), 1);
}
.quiUser-bg {
background-color: rgba(var(--quiUser), 1);
}
.quiUser-bd {
border-color: rgba(var(--quiUser), 1);
}
.sixUser {
color: rgba(var(--sixUser), 1);
}
.sixUser-bg {
background-color: rgba(var(--sixUser), 1);
}
.sixUser-bd {
border-color: rgba(var(--sixUser), 1);
}
.sepUser {
color: rgba(var(--sepUser), 1);
}
.sepUser-bg {
background-color: rgba(var(--sepUser), 1);
}
.sepUser-bd {
border-color: rgba(var(--sepUser), 1);
}
.octUser {
color: rgba(var(--octUser), 1);
}
.octUser-bg {
background-color: rgba(var(--octUser), 1);
}
.octUser-bd {
border-color: rgba(var(--octUser), 1);
}
.ninUser {
color: rgba(var(--ninUser), 1);
}
.ninUser-bg {
background-color: rgba(var(--ninUser), 1);
}
.ninUser-bd {
border-color: rgba(var(--ninUser), 1);
}
.decUser {
color: rgba(var(--decUser), 1);
}
.decUser-bg {
background-color: rgba(var(--decUser), 1);
}
.decUser-bd {
border-color: rgba(var(--decUser), 1);
}

1
src/static_src/vendor/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,106 @@
{% load lyric_extras %}
<div id="id_applets_container">
<div id="id_applet_menu" style="display:none;">
<form
hx-post="{% url "toggle_applets" %}"
hx-target="#id_applets_container"
hx-swap="outerHTML"
>
{% csrf_token %}
{% for entry in applets %}
<label>
<input
type="checkbox"
name="applets"
value="{{ entry.applet.slug }}"
{% if entry.visible %}checked{% endif %}
>
{{ entry.applet.name }}
</label>
{% endfor %}
<div class="menu-btns">
<button type="submit" class="btn btn-confirm">OK</button>
<button type="button" id="id_applet_menu_cancel" class="btn btn-cancel">NVM</button>
</div>
</form>
</div>
{% for entry in applets %}
{% if entry.visible %}
{% if entry.applet.slug == "new-list" %}
<section
id="id_applet_new_list"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Start a new to-do list</h2>
{% url "new_list" as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
</section>
{% elif entry.applet.slug == "my-lists" %}
<section
id="id_applet_my_lists"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<a href="{% url 'my_lists' user.id %}" class="my-lists-main">My lists:</a>
<div class="my-lists-container">
<ul>
{% for list in recent_lists %}
<li>
<a href="{{ list.get_absolute_url }}">{{ list.name }}</a>
</li>
{% empty %}
<li>No lists yet.</li>
{% endfor %}
</ul>
</div>
</section>
{% elif entry.applet.slug == "username" %}
<section
id="id_applet_username"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<form method="POST" action="{% url "set_profile" %}">
{% csrf_token %}
<div class="username-field">
<span class="username-at">@</span>
<input
id="id_new_username"
name="username"
required
value="{{ user.username|default:'' }}"
autocomplete="off"
placeholder="username"
data-original="{{ user.username|default:'' }}"
oninput="this.closest('form').querySelector('.save-btn').hidden = (this.value === this.dataset.original)"
onblur="this.scrollLeft = 0"
>
</div>
<button type="submit" class="btn btn-confirm save-btn" hidden>OK</button>
</form>
</section>
{% elif entry.applet.slug == "palette" %}
<section
id="id_applet_palette"
class="palette"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<div class="palette-scroll">
{% for palette in palettes %}
<div class="palette-item">
<div class="swatch {{ palette.name }}{% if user_palette == palette.name %} active{% endif %}{% if palette.locked %} locked{% endif %}"></div>
{% if not palette.locked %}
<form method="POST" action="{% url "set_palette" %}">
{% csrf_token %}
<button type="submit" name="palette" value="{{ palette.name }}" class="btn btn-confirm">OK</button>
</form>
{% else %}
<span class="btn btn-disabled">&times;</span>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endif %}
{% endfor %}
</div>

View File

@@ -3,7 +3,7 @@
<input <input
id="id_text" id="id_text"
name="text" name="text"
class="form-control form-control-lg{% if form.errors %} is-invalid{% endif %}" class="form-control form-control-lg{% if form.errors.text %} is-invalid{% endif %}"
placeholder="Enter a to-do item" placeholder="Enter a to-do item"
value="{{ form.text.value | default:'' }}" value="{{ form.text.value | default:'' }}"
aria-describedby="id_text_feedback" aria-describedby="id_text_feedback"

View File

@@ -2,5 +2,6 @@
<script> <script>
window.onload = () => { window.onload = () => {
initialize("#id_text"); initialize("#id_text");
initGearMenu();
}; };
</script> </script>

View File

@@ -1,13 +1,18 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% load lyric_extras %}
{% block title_text %}Start a new to-do list{% endblock title_text %} {% block title_text %}Dashboard{% endblock title_text %}
{% block header_text %}Start a new to-do list{% endblock header_text %} {% block header_text %}Dashboard{% endblock header_text %}
{% block extra_header %}
{% url "new_list" as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
{% endblock extra_header %}
{% block scripts %} {% block scripts %}
{% include "apps/dashboard/_partials/_scripts.html" %} {% include "apps/dashboard/_partials/_scripts.html" %}
{% endblock scripts %} {% endblock scripts %}
{% block content %}
{% if user.is_authenticated %}
<div id="id_dash_content">
<button id="id_dash_gear">&#9881;</button>
{% include "apps/dashboard/_partials/_applets.html" %}
</div>
{% endif %}
{% endblock content %}

View File

@@ -1,4 +1,5 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% load lyric_extras %}
{% block title_text %}Your to-do list{% endblock title_text %} {% block title_text %}Your to-do list{% endblock title_text %}
{% block header_text %}Your to-do list{% endblock header_text %} {% block header_text %}Your to-do list{% endblock header_text %}
@@ -11,8 +12,8 @@
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<small>List created by: <span id="id_list_owner">{{ list.owner.email }}</span></small>
<div class="col-lg-6"> <div class="col-lg-6">
<small>List created by: <span id="id_list_owner">{{ list.owner|display_name }}</span></small>
<table id="id_list_table" class="table"> <table id="id_list_table" class="table">
{% for item in list.item_set.all %} {% for item in list.item_set.all %}
<tr><td>{{ forloop.counter }}. {{ item.text }}</td></tr> <tr><td>{{ forloop.counter }}. {{ item.text }}</td></tr>
@@ -29,22 +30,22 @@
<input <input
id="id_recipient" id="id_recipient"
name="recipient" name="recipient"
class="form-control form-control-lg{% if form.errors %} is-invalid{% endif %}" class="form-control form-control-lg{% if form.errors.recipient %} is-invalid{% endif %}"
placeholder="friend@example.com" placeholder="friend@example.com"
aria-describedby="id_recipient_feedback" aria-describedby="id_recipient_feedback"
required required
/> />
{% if form.errors %} {% if form.errors.recipient %}
<div id="id_recipient_feedback" class="invalid-feedback"> <div id="id_recipient_feedback" class="invalid-feedback">
{{ form.errors.recipient.0 }} {{ form.errors.recipient.0 }}
</div> </div>
{% endif %} {% endif %}
<button type="submit" class="btn btn-primary">Share</button> <button type="submit" class="btn btn-primary btn-xl">Share</button>
</form> </form>
<small>List shared with: <small>List shared with:
{% for user in list.shared_with.all %} {% for user in list.shared_with.all %}
<span class="list-recipient">{{ user.email }}</span> <span class="list-recipient">{{ user|display_name }}</span>
{% endfor %} {% endfor %}
</small> </small>
</div> </div>

View File

@@ -1,9 +1,10 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% load lyric_extras %}
{% block header_text %}{{ user.email }}'s lists{% endblock header_text %} {% block header_text %}{{ user|display_name }}'s lists{% endblock header_text %}
{% block content %} {% block content %}
<h3>{{ owner.email }}'s lists</h3> <h3>{{ owner|display_name }}'s lists</h3>
<ul> <ul>
{% for list in owner.lists.all %} {% for list in owner.lists.all %}
<li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li> <li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li>

View File

@@ -0,0 +1,5 @@
<footer id="id_footer">
<div class="footer-container">
<small>&copy;2026 Dis Co.</small>
</div>
</footer>

View File

@@ -0,0 +1,39 @@
{% load lyric_extras %}
<nav class="navbar">
<div class="container-fluid">
<a href="/" class="navbar-brand">
<h1>Welcome,<br>Earthman</h1>
</a>
{% if user.email %}
<div class="navbar-text">
<span class="navbar-label">
Logged in as
</span>
<span class="navbar-identity">
@{{ user|display_name }}
</span>
</div>
<form method="POST" action="{% url "logout" %}">
{% csrf_token %}
<button id="id_logout" class="btn btn-primary btn-xl" type="submit">
Log Out
</button>
</form>
{% else %}
<form method="POST" action="{% url "send_login_email" %}">
<div class="input-group">
<label for="id_email_input" class="navbar-text me-2">
enter email for login:
</label>
<input
id="id_email_input"
name="email"
class="form-control"
placeholder="your@email.here"
>
{% csrf_token %}
</div>
</form>
{% endif %}
</div>
</nav>

View File

@@ -1,5 +1,9 @@
{% load compress %}
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="dark"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -7,43 +11,14 @@
<meta name="author" content="Disco DeDisco"> <meta name="author" content="Disco DeDisco">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title>Earthman RPG | {% block title_text %}{% endblock title_text %}</title> <title>Earthman RPG | {% block title_text %}{% endblock title_text %}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css"/> {% compress css %}
<link type="text/x-scss" rel="stylesheet" href="{% static 'scss/core.scss' %}">
{% endcompress %}
</head> </head>
<body> <body class="{{ user_palette }} {{ page_class|default:'' }}">
<div class="container"> <div class="container">
<nav class="navbar"> {% include "core/_partials/_navbar.html" %}
<div class="container-fluid">
<a href="/" class="navbar-brand">
<h1>Welcome, Earthman</h1>
</a>
{% if user.email %}
<a class="navbar-link" href="{% url 'my_lists' user.id %}">My lists</a>
<span class="navbar-text">Logged in as {{ user.email }}</span>
<form method="POST" action="{% url "logout" %}">
{% csrf_token %}
<button id="id_logout" class="btn btn-outline-secondary" type="submit">
Log Out
</button>
</form>
{% else %}
<form method="POST" action="{% url "send_login_email" %}">
<div class="input-group">
<label for="id_email_input" class="navbar-text me-2">
enter email for login:
</label>
<input
id="id_email_input"
name="email"
class="form-control"
placeholder="your@email.here"
>
{% csrf_token %}
</div>
</form>
{% endif %}
</div>
</nav>
{% if messages %} {% if messages %}
<div class="row"> <div class="row">
@@ -59,9 +34,9 @@
</div> </div>
{% endif %} {% endif %}
<div class="row justify-content-center p-5 bg-body-tertiary rounded-3"> <div class="row">
<div class="col-lg-6 text-center"> <div class="col-lg-6">
<h2 class="display-1 mb-4">{% block header_text %}{% endblock header_text %}</h2> <h2 >{% block header_text %}{% endblock header_text %}</h2>
{% block extra_header %} {% block extra_header %}
{% endblock extra_header %} {% endblock extra_header %}
</div> </div>
@@ -71,12 +46,29 @@
{% endblock content %} {% endblock content %}
</div> </div>
{% include "core/_partials/_footer.html" %}
{% block scripts %}
{% endblock scripts %}
<script src="{% static "vendor/htmx.min.js" %}"></script>
<script>
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');
});
function getCookie(name) {
let val = null;
if (document.cookie) {
for (let c of document.cookie.split(';')) {
c = c.trim();
if (c.startsWith(name + '=')) {
val = decodeURIComponent(c.substring(name.length + 1));
break;
}
}
}
return val;
}
</script>
</body> </body>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/js/bootstrap.min.js"
></script>
{% block scripts %}
{% endblock scripts %}
</html> </html>