Add markdown exports and tighten tag visibility
This commit is contained in:
@@ -189,6 +189,13 @@ class CollectionForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class ApiKeyForm(forms.ModelForm):
|
class ApiKeyForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, user=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
self.fields['tags'].queryset = Tag.objects.filter(items__owner=user).distinct().order_by('name')
|
||||||
|
else:
|
||||||
|
self.fields['tags'].queryset = Tag.objects.none()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ApiKey
|
model = ApiKey
|
||||||
fields = ['name', 'tags']
|
fields = ['name', 'tags']
|
||||||
|
|||||||
@@ -13,8 +13,11 @@
|
|||||||
{% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %}
|
{% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary" href="{% url 'collection_export' collection.id %}" hx-boost="false">{% trans "Export" %}</a>
|
||||||
{% if can_edit %}<a class="btn btn-dark" href="{% if collection.mode == 'tags' %}{% url 'collection_edit' collection.id %}{% else %}{% url 'collection_manage' collection.id %}{% endif %}">{% trans "Edit collection" %}</a>{% endif %}
|
{% if can_edit %}<a class="btn btn-dark" href="{% if collection.mode == 'tags' %}{% url 'collection_edit' collection.id %}{% else %}{% url 'collection_manage' collection.id %}{% endif %}">{% trans "Edit collection" %}</a>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% if collection.description %}<div class="content lead mt-4">{{ collection.description|markdown }}</div>{% endif %}
|
{% if collection.description %}<div class="content lead mt-4">{{ collection.description|markdown }}</div>{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@
|
|||||||
|
|
||||||
<div class="collection-flow content">
|
<div class="collection-flow content">
|
||||||
{% if collection.mode == 'tags' %}
|
{% if collection.mode == 'tags' %}
|
||||||
{% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section">{{ item|collection_item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No entries match the selected tags and types yet." %}</div>{% endfor %}
|
{% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section{% if item.kind == 'todo' %} d-flex gap-3 align-items-start{% endif %}">{% if item.kind == 'todo' %}<form method="post" action="{% url 'toggle_done' item.id %}" hx-boost="false" class="pt-1">{% csrf_token %}<input type="hidden" name="next" value="{{ request.get_full_path }}#item-{{ item.id }}"><input class="form-check-input" type="checkbox" {% if item.is_done %}checked{% endif %} {% if item.can_toggle_done %}onchange="this.form.submit()"{% else %}disabled{% endif %} aria-label="{% trans 'Done' %}"></form><div class="flex-grow-1 {% if item.is_done %}text-decoration-line-through text-muted{% endif %}">{% endif %}{{ item|collection_item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}{% if item.kind == 'todo' %}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No entries match the selected tags and types yet." %}</div>{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for section in sections %}{% include 'core/_collection_section_read.html' with section=section %}{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
|
{% for section in sections %}{% include 'core/_collection_section_read.html' with section=section %}{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<aside class="col-lg-3 order-lg-2 mobile-filter-sidebar">
|
<aside class="col-lg-3 order-lg-2 mobile-filter-sidebar">
|
||||||
<div class="filter-panel">
|
<div class="filter-panel">
|
||||||
<div class="fw-semibold mb-2 filter-panel-title">{% trans "Filter" %}</div>
|
<div class="d-flex justify-content-between align-items-center mb-2 filter-panel-title"><div class="fw-semibold">{% trans "Filter" %}</div><a class="btn btn-sm btn-outline-secondary" href="{% url 'export_items' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-boost="false">{% trans "Export" %}</a></div>
|
||||||
<div class="d-flex flex-wrap gap-2 mb-3 filter-buttons">
|
<div class="d-flex flex-wrap gap-2 mb-3 filter-buttons">
|
||||||
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a>
|
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a>
|
||||||
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">{% trans "Notes" %}{% if active_kind == 'note' %} ×{% endif %}</a>
|
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">{% trans "Notes" %}{% if active_kind == 'note' %} ×{% endif %}</a>
|
||||||
|
|||||||
+122
-1
@@ -241,13 +241,18 @@ class NavigationAndLayoutTests(TestCase):
|
|||||||
response = self.client.get(reverse('edit_item', args=[item.id]))
|
response = self.client.get(reverse('edit_item', args=[item.id]))
|
||||||
self.assertContains(response, 'inline-edit-form')
|
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 = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly')
|
||||||
open_todo.sync_metadata()
|
open_todo.sync_metadata()
|
||||||
done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True)
|
done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True)
|
||||||
done_todo.sync_metadata()
|
done_todo.sync_metadata()
|
||||||
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Note #noteonly')
|
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Note #noteonly')
|
||||||
note.sync_metadata()
|
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')
|
response = self.client.get(f'{reverse("index")}?status=archive')
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
@@ -257,11 +262,30 @@ class NavigationAndLayoutTests(TestCase):
|
|||||||
self.assertNotIn('#noteonly', filter_tags)
|
self.assertNotIn('#noteonly', filter_tags)
|
||||||
self.assertIn("'openonly'", html)
|
self.assertIn("'openonly'", html)
|
||||||
self.assertIn("'noteonly'", html)
|
self.assertIn("'noteonly'", html)
|
||||||
|
self.assertIn("'publiconly'", html)
|
||||||
|
self.assertNotIn('foreignonly', html)
|
||||||
|
|
||||||
response = self.client.get(f'{reverse("tag_list")}?status=archive')
|
response = self.client.get(f'{reverse("tag_list")}?status=archive')
|
||||||
self.assertContains(response, '#archiveonly')
|
self.assertContains(response, '#archiveonly')
|
||||||
self.assertNotContains(response, '#openonly')
|
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):
|
def test_due_filter_also_shows_next_seven_days_separately(self):
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
due = timezone.make_aware(datetime.combine(today, time(12, 0)))
|
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]))
|
manage_response = self.client.get(reverse('collection_manage', args=[collection.id]))
|
||||||
self.assertRedirects(manage_response, reverse('collection_edit', 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):
|
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 = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Matching note #project')
|
||||||
note.sync_metadata()
|
note.sync_metadata()
|
||||||
@@ -617,6 +671,35 @@ class CollectionTests(TestCase):
|
|||||||
self.assertContains(response, 'name="include_links"')
|
self.assertContains(response, 'name="include_links"')
|
||||||
self.assertContains(response, 'name="include_journal"')
|
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):
|
def test_collection_opens_as_continuous_reading_view(self):
|
||||||
collection = Collection.objects.create(owner=self.user, title='Reading view')
|
collection = Collection.objects.create(owner=self.user, title='Reading view')
|
||||||
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First paragraph')
|
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First paragraph')
|
||||||
@@ -761,6 +844,31 @@ class ApiItemFilterTests(TestCase):
|
|||||||
item.sync_metadata()
|
item.sync_metadata()
|
||||||
return item
|
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):
|
def test_api_key_scope_and_request_tags_are_combined(self):
|
||||||
matching = self.create_item('Keyboard shortcuts #help #tasten')
|
matching = self.create_item('Keyboard shortcuts #help #tasten')
|
||||||
self.create_item('Git help #help #git')
|
self.create_item('Git help #help #git')
|
||||||
@@ -775,6 +883,19 @@ class ApiItemFilterTests(TestCase):
|
|||||||
ids = [item['id'] for item in response.json()['items']]
|
ids = [item['id'] for item in response.json()['items']]
|
||||||
self.assertEqual(ids, [matching.id])
|
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):
|
def test_api_items_supports_limit_and_search(self):
|
||||||
self.create_item('First #help #shell')
|
self.create_item('First #help #shell')
|
||||||
second = self.create_item('Second #help #shell keyboard')
|
second = self.create_item('Second #help #shell keyboard')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ urlpatterns = [
|
|||||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||||
path('invite/<str:token>/', views.accept_user_invite, name='accept_user_invite'),
|
path('invite/<str:token>/', views.accept_user_invite, name='accept_user_invite'),
|
||||||
path('new/', views.new_item, name='new_item'),
|
path('new/', views.new_item, name='new_item'),
|
||||||
|
path('export/', views.export_items, name='export_items'),
|
||||||
path('journal/', views.journal, name='journal'),
|
path('journal/', views.journal, name='journal'),
|
||||||
path('kanban/', views.kanban_list, name='kanban_list'),
|
path('kanban/', views.kanban_list, name='kanban_list'),
|
||||||
path('kanban/<int:pk>/', views.kanban_detail, name='kanban_detail'),
|
path('kanban/<int:pk>/', views.kanban_detail, name='kanban_detail'),
|
||||||
@@ -25,6 +26,7 @@ urlpatterns = [
|
|||||||
path('collections/<int:pk>/', views.collection_detail, name='collection_detail'),
|
path('collections/<int:pk>/', views.collection_detail, name='collection_detail'),
|
||||||
path('collections/<int:pk>/manage/', views.collection_manage, name='collection_manage'),
|
path('collections/<int:pk>/manage/', views.collection_manage, name='collection_manage'),
|
||||||
path('collections/<int:pk>/edit/', views.collection_edit, name='collection_edit'),
|
path('collections/<int:pk>/edit/', views.collection_edit, name='collection_edit'),
|
||||||
|
path('collections/<int:pk>/export/', views.collection_export, name='collection_export'),
|
||||||
path('collections/<int:pk>/delete/', views.collection_delete, name='collection_delete'),
|
path('collections/<int:pk>/delete/', views.collection_delete, name='collection_delete'),
|
||||||
path('items/<int:pk>/', views.item_detail, name='item_detail'),
|
path('items/<int:pk>/', views.item_detail, name='item_detail'),
|
||||||
path('items/<int:pk>/card/', views.item_card, name='item_card'),
|
path('items/<int:pk>/card/', views.item_card, name='item_card'),
|
||||||
|
|||||||
+157
-11
@@ -13,6 +13,7 @@ from django.db import transaction
|
|||||||
from django.db.models import Case, Count, IntegerField, ProtectedError, Q, Value, When
|
from django.db.models import Case, Count, IntegerField, ProtectedError, Q, Value, When
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@@ -21,6 +22,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from django.views.decorators.http import require_http_methods, require_POST
|
from django.views.decorators.http import require_http_methods, require_POST
|
||||||
from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
|
from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
|
||||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, TAG_RE, without_markdown_code
|
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, TAG_RE, without_markdown_code
|
||||||
|
from .templatetags.markdown_extras import strip_tags_outside_code
|
||||||
|
|
||||||
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
|
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
|
||||||
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
|
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
|
||||||
@@ -209,6 +211,13 @@ def user_can_edit_item(user, item):
|
|||||||
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists())
|
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists())
|
||||||
|
|
||||||
|
|
||||||
|
def visible_tags(user):
|
||||||
|
"""Tags from items the user may see; avoids leaking private tags from other users."""
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return Tag.objects.filter(items__visibility=Item.Visibility.PUBLIC).distinct().order_by('name')
|
||||||
|
return Tag.objects.filter(items__in=visible_items(user)).distinct().order_by('name')
|
||||||
|
|
||||||
|
|
||||||
def user_can_delete_item(user, item):
|
def user_can_delete_item(user, item):
|
||||||
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists())
|
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists())
|
||||||
|
|
||||||
@@ -316,6 +325,137 @@ def index_filter_tags(request, user, prefs, active_tags=None):
|
|||||||
return Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name')
|
return Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name')
|
||||||
|
|
||||||
|
|
||||||
|
def index_filtered_items(request, prefs):
|
||||||
|
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
|
||||||
|
active_tag_mode = 'all' if request.GET.get('tag_mode') == 'all' else 'any'
|
||||||
|
tag = active_tags[0] if active_tags else None
|
||||||
|
kind = request.GET.get('kind')
|
||||||
|
status = request.GET.get('status')
|
||||||
|
day = request.GET.get('day')
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
items = visible_items(request.user)
|
||||||
|
applied_preferences = False
|
||||||
|
if active_tags:
|
||||||
|
if active_tag_mode == 'all':
|
||||||
|
for tag_name in active_tags:
|
||||||
|
items = items.filter(tags__name=tag_name)
|
||||||
|
else:
|
||||||
|
items = items.filter(tags__name__in=active_tags).distinct()
|
||||||
|
if kind in dict(Item.Kind.choices):
|
||||||
|
items = items.filter(kind=kind)
|
||||||
|
elif not any([tag, status, q, day]):
|
||||||
|
applied_preferences = True
|
||||||
|
type_filter = Q()
|
||||||
|
if prefs.show_notes:
|
||||||
|
type_filter |= Q(kind=Item.Kind.NOTE)
|
||||||
|
if prefs.show_links:
|
||||||
|
type_filter |= Q(kind=Item.Kind.LINK)
|
||||||
|
if prefs.show_journal:
|
||||||
|
type_filter |= Q(kind=Item.Kind.JOURNAL)
|
||||||
|
if prefs.show_open_todos:
|
||||||
|
type_filter |= Q(kind=Item.Kind.TODO, is_done=False)
|
||||||
|
if prefs.show_done_todos:
|
||||||
|
type_filter |= Q(kind=Item.Kind.TODO, is_done=True)
|
||||||
|
items = items.filter(type_filter) if type_filter else items.none()
|
||||||
|
if day:
|
||||||
|
items = items.filter(created_at__date=day)
|
||||||
|
upcoming_end = timezone.make_aware(datetime.combine(timezone.localdate() + timedelta(days=7), time(23, 59, 59)))
|
||||||
|
if status == 'open':
|
||||||
|
items = items.filter(kind=Item.Kind.TODO, is_done=False)
|
||||||
|
elif status == 'due':
|
||||||
|
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__isnull=False, due_at__lte=upcoming_end).order_by('due_at', '-created_at')
|
||||||
|
elif status == 'archive':
|
||||||
|
items = items.filter(kind=Item.Kind.TODO, is_done=True)
|
||||||
|
elif not applied_preferences:
|
||||||
|
items = items.exclude(kind=Item.Kind.TODO, is_done=True)
|
||||||
|
if q:
|
||||||
|
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
||||||
|
return items, active_tags, active_tag_mode, tag, kind, status, day, q
|
||||||
|
|
||||||
|
|
||||||
|
def export_item_line(item, hide_tags=False):
|
||||||
|
content = strip_tags_outside_code(item.content) if hide_tags else (item.content or '')
|
||||||
|
lines = [line.rstrip() for line in content.strip().splitlines()]
|
||||||
|
first = lines[0] if lines else _('Untitled')
|
||||||
|
prefix = '- [x] ' if item.kind == Item.Kind.TODO and item.is_done else '- [ ] ' if item.kind == Item.Kind.TODO else '- '
|
||||||
|
output = [f'{prefix}{first}']
|
||||||
|
output.extend(f' {line}' if line else '' for line in lines[1:])
|
||||||
|
if item.comment:
|
||||||
|
output.append('')
|
||||||
|
output.extend(f' > {line}' if line else ' >' for line in item.comment.strip().splitlines())
|
||||||
|
return '\n'.join(output)
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_response(filename, markdown_text):
|
||||||
|
response = HttpResponse(markdown_text, content_type='text/markdown; charset=utf-8')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def export_items(request):
|
||||||
|
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||||
|
items, active_tags, active_tag_mode, tag, kind, status, day, q = index_filtered_items(request, prefs)
|
||||||
|
title_parts = [_('my2dos export')]
|
||||||
|
if kind:
|
||||||
|
title_parts.append(kind)
|
||||||
|
if active_tags:
|
||||||
|
title_parts.append(' '.join(f'#{tag_name}' for tag_name in active_tags))
|
||||||
|
if status:
|
||||||
|
title_parts.append(status)
|
||||||
|
if q:
|
||||||
|
title_parts.append(f'“{q}”')
|
||||||
|
lines = [f'# {" – ".join(title_parts)}', '']
|
||||||
|
lines.extend(export_item_line(item) for item in items)
|
||||||
|
return markdown_response('my2dos-export.md', '\n\n'.join(lines).strip() + '\n')
|
||||||
|
|
||||||
|
|
||||||
|
def collection_item_export(item, level=2):
|
||||||
|
content = strip_tags_outside_code(item.content)
|
||||||
|
if item.kind == Item.Kind.TODO:
|
||||||
|
return export_item_line(item, hide_tags=True)
|
||||||
|
if re.match(r'^#{1,6}\s+', content.lstrip()):
|
||||||
|
body = content
|
||||||
|
else:
|
||||||
|
body = f'{"#" * level} {content}'
|
||||||
|
if item.comment:
|
||||||
|
body = f'{body}\n\n> ' + item.comment.strip().replace('\n', '\n> ')
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def collection_sections_export(sections, level=2):
|
||||||
|
lines = []
|
||||||
|
for section in sections:
|
||||||
|
if section.item:
|
||||||
|
heading = section.title.strip() if section.title else ''
|
||||||
|
if heading:
|
||||||
|
lines.append(f'{"#" * level} {heading}')
|
||||||
|
lines.append(collection_item_export(section.item, level + 1))
|
||||||
|
else:
|
||||||
|
lines.append(collection_item_export(section.item, level))
|
||||||
|
elif section.sub_collection:
|
||||||
|
title = section.title or section.sub_collection.title
|
||||||
|
lines.append(f'{"#" * level} {title}')
|
||||||
|
if section.sub_collection.description:
|
||||||
|
lines.append(section.sub_collection.description.strip())
|
||||||
|
lines.append(collection_sections_export(section.sub_collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments'), level + 1))
|
||||||
|
return '\n\n'.join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def collection_export(request, pk):
|
||||||
|
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||||
|
lines = [f'# {collection.title}', '']
|
||||||
|
if collection.description:
|
||||||
|
lines.extend([collection.description.strip(), ''])
|
||||||
|
if collection.mode == Collection.Mode.TAGS:
|
||||||
|
lines.extend(export_item_line(item, hide_tags=True) for item in collection_tag_items(collection))
|
||||||
|
else:
|
||||||
|
body = collection_sections_export(collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection'))
|
||||||
|
if body:
|
||||||
|
lines.append(body)
|
||||||
|
return markdown_response(f'collection-{collection.pk}.md', '\n\n'.join(line for line in lines if line).strip() + '\n')
|
||||||
|
|
||||||
|
|
||||||
def index(request):
|
def index(request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -400,7 +540,7 @@ def index(request):
|
|||||||
default=Value(1), output_field=IntegerField(),
|
default=Value(1), output_field=IntegerField(),
|
||||||
)).order_by('due_rank', 'due_at', '-created_at')
|
)).order_by('due_rank', 'due_at', '-created_at')
|
||||||
return render(request, 'core/index.html', {
|
return render(request, 'core/index.html', {
|
||||||
'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': Tag.objects.all(), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines,
|
'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': visible_tags(request.user), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -435,7 +575,7 @@ def new_item(request):
|
|||||||
add_collection_section(collection, item)
|
add_collection_section(collection, item)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
return render(request, 'core/new_item.html', {
|
return render(request, 'core/new_item.html', {
|
||||||
'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
|
'form': QuickItemForm(), 'tags': visible_tags(request.user), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -474,7 +614,7 @@ def journal(request):
|
|||||||
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
|
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
|
||||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||||
return render(request, 'core/journal.html', {
|
return render(request, 'core/journal.html', {
|
||||||
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'weeks': weeks, 'year': year, 'month': month, 'selected_day': selected_day,
|
'form': QuickItemForm(), 'items': items, 'tags': visible_tags(request.user), 'weeks': weeks, 'year': year, 'month': month, 'selected_day': selected_day,
|
||||||
'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, 'overview_lines': prefs.overview_lines,
|
'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, 'overview_lines': prefs.overview_lines,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -515,7 +655,7 @@ def settings_view(request):
|
|||||||
messages.success(request, _('Default view saved.'))
|
messages.success(request, _('Default view saved.'))
|
||||||
return redirect('settings')
|
return redirect('settings')
|
||||||
elif request.POST.get('action') == 'apikey':
|
elif request.POST.get('action') == 'apikey':
|
||||||
key_form = ApiKeyForm(request.POST)
|
key_form = ApiKeyForm(request.POST, user=request.user)
|
||||||
if key_form.is_valid():
|
if key_form.is_valid():
|
||||||
api_key = key_form.save(commit=False)
|
api_key = key_form.save(commit=False)
|
||||||
api_key.owner = request.user
|
api_key.owner = request.user
|
||||||
@@ -535,7 +675,7 @@ def settings_view(request):
|
|||||||
return render(request, 'core/settings.html', {
|
return render(request, 'core/settings.html', {
|
||||||
'profile_form': UserSettingsForm(instance=request.user),
|
'profile_form': UserSettingsForm(instance=request.user),
|
||||||
'preference_form': UserPreferenceForm(instance=prefs),
|
'preference_form': UserPreferenceForm(instance=prefs),
|
||||||
'key_form': ApiKeyForm(),
|
'key_form': ApiKeyForm(user=request.user),
|
||||||
'user_invite_form': UserInviteForm(),
|
'user_invite_form': UserInviteForm(),
|
||||||
'user_invites': request.user.sent_user_invites.filter(accepted_at__isnull=True),
|
'user_invites': request.user.sent_user_invites.filter(accepted_at__isnull=True),
|
||||||
'api_keys': request.user.api_keys.prefetch_related('tags'),
|
'api_keys': request.user.api_keys.prefetch_related('tags'),
|
||||||
@@ -914,10 +1054,13 @@ def collection_edit(request, pk):
|
|||||||
|
|
||||||
def collection_detail(request, pk):
|
def collection_detail(request, pk):
|
||||||
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||||
|
tag_items = list(collection_tag_items(collection)) if collection.mode == Collection.Mode.TAGS else []
|
||||||
|
for item in tag_items:
|
||||||
|
item.can_toggle_done = item.kind == Item.Kind.TODO and user_can_edit_item(request.user, item)
|
||||||
return render(request, 'core/collection_detail.html', {
|
return render(request, 'core/collection_detail.html', {
|
||||||
'collection': collection,
|
'collection': collection,
|
||||||
'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection') if collection.is_structured else [],
|
'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection') if collection.is_structured else [],
|
||||||
'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [],
|
'tag_items': tag_items,
|
||||||
'can_edit': user_can_edit_collection(request.user, collection),
|
'can_edit': user_can_edit_collection(request.user, collection),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1034,6 +1177,9 @@ def toggle_done(request, pk):
|
|||||||
item.is_done = not item.is_done
|
item.is_done = not item.is_done
|
||||||
item.completed_at = timezone.now() if item.is_done else None
|
item.completed_at = timezone.now() if item.is_done else None
|
||||||
item.save(update_fields=['is_done', 'completed_at', 'updated_at'])
|
item.save(update_fields=['is_done', 'completed_at', 'updated_at'])
|
||||||
|
next_url = request.POST.get('next') or request.GET.get('next')
|
||||||
|
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
|
||||||
|
return redirect(next_url)
|
||||||
if request.headers.get('HX-Request') and item.is_done and request.GET.get('status') != 'archive':
|
if request.headers.get('HX-Request') and item.is_done and request.GET.get('status') != 'archive':
|
||||||
return HttpResponse('')
|
return HttpResponse('')
|
||||||
return render(request, 'core/_item.html', {'item': item})
|
return render(request, 'core/_item.html', {'item': item})
|
||||||
@@ -1060,7 +1206,7 @@ def edit_item(request, pk):
|
|||||||
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||||
if not item_scope_fits_collections(item, target_visibility, target_team):
|
if not item_scope_fits_collections(item, target_visibility, target_team):
|
||||||
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
|
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
|
||||||
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': Tag.objects.all()}, status=409)
|
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': visible_tags(request.user)}, status=409)
|
||||||
item.visibility, item.team = target_visibility, target_team
|
item.visibility, item.team = target_visibility, target_team
|
||||||
item.save()
|
item.save()
|
||||||
form.save_m2m()
|
form.save_m2m()
|
||||||
@@ -1071,7 +1217,7 @@ def edit_item(request, pk):
|
|||||||
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
|
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
|
||||||
response['HX-Trigger'] = 'tagsChanged'
|
response['HX-Trigger'] = 'tagsChanged'
|
||||||
return response
|
return response
|
||||||
return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()})
|
return render(request, 'core/_edit_form.html', {'item': item, 'tags': visible_tags(request.user)})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -1090,7 +1236,7 @@ def item_editor(request, pk):
|
|||||||
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||||
if not item_scope_fits_collections(item, target_visibility, target_team):
|
if not item_scope_fits_collections(item, target_visibility, target_team):
|
||||||
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
|
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
|
||||||
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': Tag.objects.all(), 'next_url': next_url}, status=409)
|
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': visible_tags(request.user), 'next_url': next_url}, status=409)
|
||||||
item.visibility, item.team = target_visibility, target_team
|
item.visibility, item.team = target_visibility, target_team
|
||||||
item.save()
|
item.save()
|
||||||
form.save_m2m()
|
form.save_m2m()
|
||||||
@@ -1099,7 +1245,7 @@ def item_editor(request, pk):
|
|||||||
item.sync_metadata()
|
item.sync_metadata()
|
||||||
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
return render(request, 'core/item_editor.html', {'item': item, 'tags': Tag.objects.all(), 'next_url': next_url})
|
return render(request, 'core/item_editor.html', {'item': item, 'tags': visible_tags(request.user), 'next_url': next_url})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -1188,7 +1334,7 @@ def set_default_item_kind(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def api_tags(request):
|
def api_tags(request):
|
||||||
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})
|
return JsonResponse({'tags': list(visible_tags(request.user).values_list('name', flat=True))})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
Reference in New Issue
Block a user