Add markdown exports and tighten tag visibility

This commit is contained in:
rucki
2026-09-09 16:31:01 +02:00
parent c655b82c9b
commit 7f203a7117
6 changed files with 294 additions and 15 deletions
+122 -1
View File
@@ -241,13 +241,18 @@ class NavigationAndLayoutTests(TestCase):
response = self.client.get(reverse('edit_item', args=[item.id]))
self.assertContains(response, 'inline-edit-form')
def test_tag_filter_list_depends_on_current_filters_but_composer_gets_all_tags(self):
def test_tag_filter_list_depends_on_current_filters_but_composer_gets_visible_tags(self):
open_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly')
open_todo.sync_metadata()
done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True)
done_todo.sync_metadata()
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Note #noteonly')
note.sync_metadata()
other = get_user_model().objects.create_user(username='other-tags', password='secret')
foreign = Item.objects.create(owner=other, kind=Item.Kind.NOTE, content='Foreign #foreignonly')
foreign.sync_metadata()
public = Item.objects.create(owner=other, kind=Item.Kind.NOTE, content='Public #publiconly', visibility=Item.Visibility.PUBLIC)
public.sync_metadata()
response = self.client.get(f'{reverse("index")}?status=archive')
html = response.content.decode()
@@ -257,11 +262,30 @@ class NavigationAndLayoutTests(TestCase):
self.assertNotIn('#noteonly', filter_tags)
self.assertIn("'openonly'", html)
self.assertIn("'noteonly'", html)
self.assertIn("'publiconly'", html)
self.assertNotIn('foreignonly', html)
response = self.client.get(f'{reverse("tag_list")}?status=archive')
self.assertContains(response, '#archiveonly')
self.assertNotContains(response, '#openonly')
def test_tag_api_returns_only_visible_tags(self):
own = Item.objects.create(owner=self.user, content='Own #ownapi')
own.sync_metadata()
other = get_user_model().objects.create_user(username='other-api-tags', password='secret')
foreign = Item.objects.create(owner=other, content='Foreign #foreignapi')
foreign.sync_metadata()
public = Item.objects.create(owner=other, content='Public #publicapi', visibility=Item.Visibility.PUBLIC)
public.sync_metadata()
response = self.client.get(reverse('api_tags'))
self.assertEqual(response.status_code, 200)
tags = response.json()['tags']
self.assertIn('ownapi', tags)
self.assertIn('publicapi', tags)
self.assertNotIn('foreignapi', tags)
def test_due_filter_also_shows_next_seven_days_separately(self):
today = timezone.localdate()
due = timezone.make_aware(datetime.combine(today, time(12, 0)))
@@ -589,6 +613,36 @@ class CollectionTests(TestCase):
manage_response = self.client.get(reverse('collection_manage', args=[collection.id]))
self.assertRedirects(manage_response, reverse('collection_edit', args=[collection.id]))
def test_tag_collection_todos_are_rendered_with_checkbox(self):
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Check me #project')
todo.sync_metadata()
collection = Collection.objects.create(
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
include_notes=False, include_todos=True,
)
collection.filter_tags.add(Tag.objects.get(name='project'))
response = self.client.get(collection.get_absolute_url())
self.assertContains(response, 'type="checkbox"')
self.assertContains(response, reverse('toggle_done', args=[todo.id]))
def test_tag_collection_todo_checkbox_toggles_and_returns_to_collection(self):
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Check me #project')
todo.sync_metadata()
collection = Collection.objects.create(
owner=self.user, title='Typed tags', mode=Collection.Mode.TAGS,
include_notes=False, include_todos=True,
)
collection.filter_tags.add(Tag.objects.get(name='project'))
next_url = f'{collection.get_absolute_url()}#item-{todo.id}'
response = self.client.post(reverse('toggle_done', args=[todo.id]), {'next': next_url})
self.assertRedirects(response, next_url, fetch_redirect_response=False)
todo.refresh_from_db()
self.assertTrue(todo.is_done)
def test_tag_collection_can_filter_included_item_types(self):
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Matching note #project')
note.sync_metadata()
@@ -617,6 +671,35 @@ class CollectionTests(TestCase):
self.assertContains(response, 'name="include_links"')
self.assertContains(response, 'name="include_journal"')
def test_current_list_can_be_exported_as_markdown(self):
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open task #project').sync_metadata()
Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Other note')
response = self.client.get(f'{reverse("export_items")}?kind=todo&tag=project')
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/markdown; charset=utf-8')
body = response.content.decode()
self.assertIn('- [ ] Open task #project', body)
self.assertNotIn('Other note', body)
def test_collection_can_be_exported_as_markdown(self):
collection = Collection.objects.create(owner=self.user, title='Reading view', description='Guide')
todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Todo section #project')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Note section #project')
CollectionSection.objects.create(collection=collection, item=todo, position=0)
CollectionSection.objects.create(collection=collection, item=note, position=1)
response = self.client.get(reverse('collection_export', args=[collection.id]))
self.assertEqual(response.status_code, 200)
body = response.content.decode()
self.assertIn('# Reading view', body)
self.assertIn('Guide', body)
self.assertIn('- [ ] Todo section', body)
self.assertIn('## Note section', body)
self.assertNotIn('#project', body)
def test_collection_opens_as_continuous_reading_view(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First paragraph')
@@ -761,6 +844,31 @@ class ApiItemFilterTests(TestCase):
item.sync_metadata()
return item
def test_api_key_settings_only_offer_own_tags(self):
self.client.force_login(self.user)
self.create_item('Own tag #own')
other = get_user_model().objects.create_user(username='api-other', password='secret')
other_item = Item.objects.create(owner=other, content='Other tag #foreign')
other_item.sync_metadata()
response = self.client.get(reverse('settings'))
api_section = response.content.decode().split('id="api"', 1)[1]
self.assertIn('#own', api_section)
self.assertNotIn('#foreign', api_section)
def test_api_key_cannot_be_created_with_foreign_tag(self):
self.client.force_login(self.user)
other = get_user_model().objects.create_user(username='api-other', password='secret')
other_item = Item.objects.create(owner=other, content='Other tag #foreign')
other_item.sync_metadata()
foreign_tag = Tag.objects.get(name='foreign')
response = self.client.post(reverse('settings'), {'action': 'apikey', 'name': 'Bad key', 'tags': [foreign_tag.id]})
self.assertEqual(response.status_code, 200)
self.assertFalse(ApiKey.objects.filter(owner=self.user, name='Bad key').exists())
def test_api_key_scope_and_request_tags_are_combined(self):
matching = self.create_item('Keyboard shortcuts #help #tasten')
self.create_item('Git help #help #git')
@@ -775,6 +883,19 @@ class ApiItemFilterTests(TestCase):
ids = [item['id'] for item in response.json()['items']]
self.assertEqual(ids, [matching.id])
def test_api_key_only_returns_owner_items_not_other_public_items(self):
own = self.create_item('Own public help #help')
other = get_user_model().objects.create_user(username='api-public-other', password='secret')
public = Item.objects.create(owner=other, content='Other public help #help', visibility=Item.Visibility.PUBLIC)
public.sync_metadata()
response = self.client.get(reverse('api_items'), HTTP_X_API_KEY=self.api_key.token)
self.assertEqual(response.status_code, 200)
ids = [item['id'] for item in response.json()['items']]
self.assertIn(own.id, ids)
self.assertNotIn(public.id, ids)
def test_api_items_supports_limit_and_search(self):
self.create_item('First #help #shell')
second = self.create_item('Second #help #shell keyboard')