Compare commits

..

5 Commits

15 changed files with 293 additions and 76 deletions

View File

@@ -0,0 +1,20 @@
# 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

@@ -10,6 +10,12 @@ class List(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
shared_with = models.ManyToManyField(
"lyric.User",
related_name="shared_lists",
blank=True,
)
@property @property
def name(self): def name(self):
return self.item_set.first().text return self.item_set.first().text

View File

@@ -1,7 +1,8 @@
import lxml.html import lxml.html
from unittest import skip
from django.test import TestCase from django.test import TestCase
from django.utils import html from django.utils import html
from unittest import skip
from ..forms import ( from ..forms import (
DUPLICATE_ITEM_ERROR, DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR, EMPTY_ITEM_ERROR,
@@ -9,6 +10,7 @@ from ..forms import (
from ..models import Item, List from ..models import Item, List
from apps.lyric.models import User from apps.lyric.models import User
class HomePageTest(TestCase): class HomePageTest(TestCase):
def test_uses_home_template(self): def test_uses_home_template(self):
response = self.client.get('/') response = self.client.get('/')
@@ -180,3 +182,30 @@ class MyListsTest(TestCase):
response = self.client.get(f"/apps/dashboard/users/{user1.id}/") response = self.client.get(f"/apps/dashboard/users/{user1.id}/")
# assert 403 # assert 403
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
class ShareListTest(TestCase):
def test_post_to_share_list_url_redirects_to_list(self):
our_list = List.objects.create()
alice = User.objects.create(email="alice@example.com")
response = self.client.post(
f"/apps/dashboard/{our_list.id}/share_list",
data={"recipient": "alice@example.com"},
)
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/")
def test_post_with_email_adds_user_to_shared_with(self):
our_list = List.objects.create()
alice = User.objects.create(email="alice@example.com")
self.client.post(
f"/apps/dashboard/{our_list.id}/share_list",
data={"recipient": "alice@example.com"},
)
self.assertIn(alice, our_list.shared_with.all())
def test_post_with_nonexistent_email_redirects_to_list(self):
our_list = List.objects.create()
response = self.client.post(
f"/apps/dashboard/{our_list.id}/share_list",
data={"recipient": "nobody@example.com"},
)
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/")

View File

@@ -5,4 +5,5 @@ 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('<int:list_id>/', views.view_list, name='view_list'),
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"),
] ]

View File

@@ -37,3 +37,12 @@ def my_lists(request, user_id):
if request.user.id != owner.id: if request.user.id != owner.id:
return HttpResponseForbidden() return HttpResponseForbidden()
return render(request, "apps/dashboard/my_lists.html", {"owner": owner}) return render(request, "apps/dashboard/my_lists.html", {"owner": owner})
def share_list(request, list_id):
our_list = List.objects.get(id=list_id)
try:
recipient = User.objects.get(email=request.POST["recipient"])
our_list.shared_with.add(recipient)
except User.DoesNotExist:
pass
return redirect(our_list)

View File

@@ -2,6 +2,7 @@ import os
import time import time
from datetime import datetime from datetime import datetime
from django.conf import settings
from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from pathlib import Path from pathlib import Path
from selenium import webdriver from selenium import webdriver
@@ -9,7 +10,9 @@ from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from .container_commands import reset_database from .container_commands import create_session_on_server, reset_database
from .management.commands.create_session import create_pre_authenticated_session
MAX_WAIT = 10 MAX_WAIT = 10
@@ -70,24 +73,26 @@ class FunctionalTest(StaticLiveServerTestCase):
f"{self.__class__.__name__}.{self._testMethodName}-{timestamp}.{extension}" f"{self.__class__.__name__}.{self._testMethodName}-{timestamp}.{extension}"
) )
@wait @wait
def wait_for(self, fn): def wait_for(self, fn):
return fn() return fn()
def get_item_input_box(self): def create_pre_authenticated_session(self, email):
return self.browser.find_element(By.ID, "id_text") if self.test_server:
session_key = create_session_on_server(self.test_server, email)
@wait else:
def wait_for_row_in_list_table(self, row_text): session_key = create_pre_authenticated_session(email)
rows = self.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr") ## to set a cookie we need to first visit the domain
self.assertIn(row_text, [row.text for row in rows]) ## 404 pages load the quickest!
self.browser.get(self.live_server_url + "/404_no_such_url/")
def add_list_item(self, item_text): self.browser.add_cookie(
num_rows = len(self.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr")) dict(
self.get_item_input_box().send_keys(item_text) name=settings.SESSION_COOKIE_NAME,
self.get_item_input_box().send_keys(Keys.ENTER) value=session_key,
item_number = num_rows + 1 path="/",
self.wait_for_row_in_list_table(f"{item_number}. {item_text}") )
)
@wait @wait
def wait_to_be_logged_in(self, email): def wait_to_be_logged_in(self, email):

View File

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

View File

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

View File

@@ -2,23 +2,25 @@ 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 .list_page import ListPage
class LayoutAndStylingTest(FunctionalTest): class LayoutAndStylingTest(FunctionalTest):
def test_layout_and_styling(self): def test_layout_and_styling(self):
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self)
self.browser.set_window_size(1024, 768) self.browser.set_window_size(1024, 768)
# print("Viewport width:", self.browser.execute_script("return window.innerWidth")) # print("Viewport width:", self.browser.execute_script("return window.innerWidth"))
inputbox = self.get_item_input_box() inputbox = list_page.get_item_input_box()
self.assertAlmostEqual( self.assertAlmostEqual(
inputbox.location['x'] + inputbox.size['width'] / 2, inputbox.location['x'] + inputbox.size['width'] / 2,
512, 512,
delta=10, delta=10,
) )
self.add_list_item("testing") list_page.add_list_item("testing")
inputbox = self.get_item_input_box() inputbox = list_page.get_item_input_box()
self.assertAlmostEqual( self.assertAlmostEqual(
inputbox.location['x'] + inputbox.size['width'] / 2, inputbox.location['x'] + inputbox.size['width'] / 2,
512, 512,

View File

@@ -2,6 +2,8 @@ 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 .list_page import ListPage
class ItemValidationTest(FunctionalTest): class ItemValidationTest(FunctionalTest):
# Helper functions # Helper functions
@@ -11,43 +13,45 @@ class ItemValidationTest(FunctionalTest):
# Test methods # Test methods
def test_cannot_add_empty_list_items(self): def test_cannot_add_empty_list_items(self):
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self.get_item_input_box().send_keys(Keys.ENTER) list_page = ListPage(self)
list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid") lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
) )
self.get_item_input_box().send_keys("Purchase milk") list_page.get_item_input_box().send_keys("Purchase milk")
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:valid") lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:valid")
) )
self.get_item_input_box().send_keys(Keys.ENTER) list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table("1. Purchase milk") list_page.wait_for_row_in_list_table("Purchase milk", 1)
self.get_item_input_box().send_keys(Keys.ENTER) list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table("1. Purchase milk") list_page.wait_for_row_in_list_table("Purchase milk", 1)
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid") lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
) )
self.get_item_input_box().send_keys("Make tea") list_page.get_item_input_box().send_keys("Make tea")
self.wait_for( self.wait_for(
lambda: self.browser.find_element( lambda: self.browser.find_element(
By.CSS_SELECTOR, By.CSS_SELECTOR,
"#id_text:valid", "#id_text:valid",
) )
) )
self.get_item_input_box().send_keys(Keys.ENTER) list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table("2. Make tea") 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.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self.add_list_item("Witness divinity") list_page = ListPage(self)
list_page.add_list_item("Witness divinity")
self.get_item_input_box().send_keys("Witness divinity") list_page.get_item_input_box().send_keys("Witness divinity")
self.get_item_input_box().send_keys(Keys.ENTER) list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for( self.wait_for(
lambda: self.assertEqual( lambda: self.assertEqual(
@@ -58,14 +62,15 @@ class ItemValidationTest(FunctionalTest):
def test_error_messages_are_cleared_on_input(self): def test_error_messages_are_cleared_on_input(self):
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self.add_list_item("Gobbledygook") list_page = ListPage(self)
self.get_item_input_box().send_keys("Gobbledygook") list_page.add_list_item("Gobbledygook")
self.get_item_input_box().send_keys(Keys.ENTER) list_page.get_item_input_box().send_keys("Gobbledygook")
list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for( self.wait_for(
lambda: self.assertTrue(self.get_error_element().is_displayed()) lambda: self.assertTrue(self.get_error_element().is_displayed())
) )
self.get_item_input_box().send_keys("a") list_page.get_item_input_box().send_keys("a")
self.wait_for( self.wait_for(
lambda: self.assertFalse(self.get_error_element().is_displayed()) lambda: self.assertFalse(self.get_error_element().is_displayed())

View File

@@ -1,43 +1,22 @@
from django.conf import settings
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from .container_commands import create_session_on_server from .list_page import ListPage
from .management.commands.create_session import create_pre_authenticated_session from .my_lists_page import MyListsPage
class MyListsTest(FunctionalTest): class MyListsTest(FunctionalTest):
def create_pre_authenticated_session(self, email):
if self.test_server:
session_key = create_session_on_server(self.test_server, email)
else:
session_key = create_pre_authenticated_session(email)
## to set a cookie we need to first visit the domain
## 404 pages load the quickest!
self.browser.get(self.live_server_url + "/404_no_such_url/")
self.browser.add_cookie(
dict(
name=settings.SESSION_COOKIE_NAME,
value=session_key,
path="/",
)
)
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("discoman@example.com")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self.add_list_item("Reticulate splines") list_page = ListPage(self)
self.add_list_item("Regurgitate spines") list_page.add_list_item("Reticulate splines")
list_page.add_list_item("Regurgitate spines")
first_list_url = self.browser.current_url first_list_url = self.browser.current_url
self.browser.find_element(By.LINK_TEXT, "My lists").click() MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.wait_for(
lambda: self.assertIn(
"discoman@example.com",
self.browser.find_element(By.CSS_SELECTOR, "h2").text,
)
)
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")
@@ -48,17 +27,14 @@ class MyListsTest(FunctionalTest):
) )
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self.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() self.browser.find_element(By.LINK_TEXT, "My lists").click()
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")
) )
self.browser.find_element(By.LINK_TEXT, "Ribbon of death").click() MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, second_list_url)
)
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

@@ -0,0 +1,54 @@
from selenium import webdriver
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .list_page import ListPage
from .my_lists_page import MyListsPage
# Helper fns
def quit_if_possible(browser):
try:
browser.quit()
except:
pass
# Test mdls
class SharingTest(FunctionalTest):
def test_can_share_a_list_with_another_user(self):
self.create_pre_authenticated_session("discoman@example.com")
disco_browser = self.browser
self.addCleanup(lambda: quit_if_possible(disco_browser))
ali_browser = webdriver.Firefox()
self.addCleanup(lambda: quit_if_possible(ali_browser))
self.browser = ali_browser
self.create_pre_authenticated_session("alice@example.com")
self.browser = disco_browser
self.browser.get(self.live_server_url)
list_page = ListPage(self).add_list_item("Send help")
share_box = list_page.get_share_box()
self.assertEqual(
share_box.get_attribute("placeholder"),
"friend@example.com",
)
list_page.share_list_with("alice@example.com")
self.browser = ali_browser
MyListsPage(self).go_to_my_lists_page("alice@example.com")
self.browser.find_element(By.LINK_TEXT, "Send help").click()
self.wait_for(
lambda: self.assertEqual(list_page.get_list_owner(), "discoman@example.com")
)
list_page.add_list_item("At your command, Disco King")
self.browser = disco_browser
self.browser.refresh()
list_page.wait_for_row_in_list_table("At your command, Disco King", 2)

View File

@@ -2,32 +2,36 @@ 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 .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.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self)
self.assertIn('Earthman RPG', self.browser.title) self.assertIn('Earthman RPG', self.browser.title)
header_text = self.browser.find_element(By.TAG_NAME, 'h1').text header_text = self.browser.find_element(By.TAG_NAME, 'h1').text
self.assertIn('Welcome', header_text) self.assertIn('Welcome', header_text)
inputbox = self.get_item_input_box() inputbox = list_page.get_item_input_box()
self.assertEqual(inputbox.get_attribute('placeholder'), 'Enter a to-do item') self.assertEqual(inputbox.get_attribute('placeholder'), 'Enter a to-do item')
inputbox.send_keys('Buy peacock feathers') inputbox.send_keys('Buy peacock feathers')
inputbox.send_keys(Keys.ENTER) inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1. Buy peacock feathers') list_page.wait_for_row_in_list_table("Buy peacock feathers", 1)
self.add_list_item("Use peacock feathers to make a fly") list_page.add_list_item("Use peacock feathers to make a fly")
self.wait_for_row_in_list_table('2. Use peacock feathers to make a fly') list_page.wait_for_row_in_list_table("Use peacock feathers to make a fly", 2)
self.wait_for_row_in_list_table('1. Buy peacock feathers') 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.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self.add_list_item("Buy peacock feathers") list_page = ListPage(self)
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, '/apps/dashboard/.+')
@@ -35,10 +39,11 @@ class NewVisitorTest(FunctionalTest):
self.browser.delete_all_cookies() self.browser.delete_all_cookies()
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
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
self.assertNotIn('Buy peacock feathers', page_text) self.assertNotIn('Buy peacock feathers', page_text)
self.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, '/apps/dashboard/.+')

View File

@@ -11,6 +11,7 @@
{% 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">
<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 %}
@@ -19,6 +20,35 @@
</table> </table>
</div> </div>
</div> </div>
<div class="row justify-content-center">
<div class="col-lg-6">
<form method="POST" action="{% url "share_list" list.id %}">
{% csrf_token %}
<input
id="id_recipient"
name="recipient"
class="form-control form-control-lg{% if form.errors %} is-invalid{% endif %}"
placeholder="friend@example.com"
aria-describedby="id_recipient_feedback"
required
/>
{% if form.errors %}
<div id="id_recipient_feedback" class="invalid-feedback">
{{ form.errors.recipient.0 }}
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Share</button>
</form>
<small>List shared with:
{% for user in list.shared_with.all %}
<span class="list-recipient">{{ user.email }}</span>
{% endfor %}
</small>
</div>
</div>
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}

View File

@@ -9,4 +9,10 @@
<li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li> <li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
<h3>Lists shared with me</h3>
<ul>
{% for list in owner.shared_lists.all %}
<li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li>
{% endfor %}
</ul>
{% endblock content %} {% endblock content %}