From 0e5e2de3962ef3fb6d97b777a36f3517c62b8064 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 15:11:14 +0900 Subject: [PATCH 01/41] Introduce full_clean() on all models on save() --- projectify/conftest.py | 6 ++++- .../corporate/test/views/test_stripe.py | 2 +- projectify/lib/models.py | 7 +++++- projectify/onboarding/tests/test_views.py | 8 +++--- projectify/user/test/views/test_auth.py | 8 +++--- projectify/user/test/views/test_user.py | 12 ++++----- .../workspace/test/services/test_workspace.py | 5 +--- .../workspace/test/views/test_project.py | 25 +++++++++++++------ projectify/workspace/test/views/test_task.py | 4 +-- .../workspace/test/views/test_workspace.py | 6 ++--- 10 files changed, 50 insertions(+), 33 deletions(-) diff --git a/projectify/conftest.py b/projectify/conftest.py index c00ed9453..9489c7fc9 100644 --- a/projectify/conftest.py +++ b/projectify/conftest.py @@ -510,7 +510,11 @@ def post(faker: Faker, now: datetime, post_content: PostContent) -> Post: """Return a blog post.""" title = faker.sentence() return Post.objects.create( - title=title, slug=faker.slug(), body=post_content, published=now.date() + title=title, + slug=faker.slug(), + body=post_content, + published=now.date(), + author=faker.name(), ) diff --git a/projectify/corporate/test/views/test_stripe.py b/projectify/corporate/test/views/test_stripe.py index 3cd87de3b..e96857d09 100644 --- a/projectify/corporate/test/views/test_stripe.py +++ b/projectify/corporate/test/views/test_stripe.py @@ -133,7 +133,7 @@ def test_customer_subscription_deleted( event["data"]["object"].customer = paid_customer.stripe_customer_id stripe_client.return_value.construct_event.return_value = event - with django_assert_num_queries(2): + with django_assert_num_queries(5): response = client.post(resource_url, headers=headers) assert response.status_code == 200, response.content paid_customer.refresh_from_db() diff --git a/projectify/lib/models.py b/projectify/lib/models.py index d28f447cd..7e71528a0 100644 --- a/projectify/lib/models.py +++ b/projectify/lib/models.py @@ -140,7 +140,12 @@ class BaseModel(Model): created = CreationDateTimeField(verbose_name=_("created")) modified = ModificationDateTimeField(verbose_name=_("modified")) - # TODO add full_clean() on save() + + def save(self, *args: Any, **kwargs: Any) -> None: + """Run full_clean().""" + # At the time of writing, this applies to _all_ Projectify models + self.full_clean() + return super().save(*args, **kwargs) class Meta: """Make this model abstract.""" diff --git a/projectify/onboarding/tests/test_views.py b/projectify/onboarding/tests/test_views.py index cac138c4c..2dae287c1 100644 --- a/projectify/onboarding/tests/test_views.py +++ b/projectify/onboarding/tests/test_views.py @@ -54,7 +54,7 @@ def test_post_about_you( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test setting a name.""" - with django_assert_num_queries(10): + with django_assert_num_queries(14): assert ( user_client.post( resource_url, {"preferred_name": "Test User"} @@ -91,7 +91,7 @@ def test_workspace_creation( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Create a new workspace.""" - with django_assert_num_queries(10): + with django_assert_num_queries(21): assert ( user_client.post( resource_url, {"title": "Woof woof"} @@ -138,7 +138,7 @@ def test_project_creation( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test creating a new project.""" - with django_assert_num_queries(11): + with django_assert_num_queries(13): response = user_client.post(resource_url, {"title": "BarFoo"}) assert response.status_code == 302 project = Project.objects.get(title="BarFoo") @@ -191,7 +191,7 @@ def test_post_new_task( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Create a new task.""" - with django_assert_num_queries(12): + with django_assert_num_queries(16): assert ( user_client.post( resource_url, {"title": "Test Task"} diff --git a/projectify/user/test/views/test_auth.py b/projectify/user/test/views/test_auth.py index b1feb9099..b2242f236 100644 --- a/projectify/user/test/views/test_auth.py +++ b/projectify/user/test/views/test_auth.py @@ -83,7 +83,7 @@ def test_signing_up( "tos_agreed": True, "privacy_policy_agreed": True, } - with django_assert_num_queries(11): + with django_assert_num_queries(15): response = client.post(resource_url, data, follow=True) assert response.status_code == 200, response.content assert User.objects.count() == 1 @@ -264,7 +264,7 @@ def test_confirm_email( ) token = user_make_token(user=user, kind="confirm_email_address") url = reverse("users:confirm-email", args=("hello@world.com", token)) - with django_assert_num_queries(8): + with django_assert_num_queries(12): response = client.get(url, follow=True) assert response.status_code == 200, response.content @@ -308,7 +308,7 @@ def test_log_in( ) -> None: """Test logging in a user.""" data = {"email": user.email, "password": password} - with django_assert_num_queries(15): + with django_assert_num_queries(19): response = client.post(resource_url, data) assert response.status_code == 302, response.content assert "sessionid" in response.cookies @@ -546,7 +546,7 @@ def test_confirm_password_reset( new_pw = "evenmoresecurepassword123" data = {"new_password": new_pw, "new_password_confirm": new_pw} url = reverse("users:confirm-password-reset", args=(user.email, token)) - with django_assert_num_queries(8): + with django_assert_num_queries(12): response = client.post(url, data) assert response.status_code == 302, response.content user.refresh_from_db() diff --git a/projectify/user/test/views/test_user.py b/projectify/user/test/views/test_user.py index f9fd71226..424c62e43 100644 --- a/projectify/user/test/views/test_user.py +++ b/projectify/user/test/views/test_user.py @@ -50,7 +50,7 @@ def test_update_preferred_name( ) -> None: """Test updating both preferred name and profile picture.""" data = {"preferred_name": "Foo", "profile_picture": ""} - with django_assert_num_queries(14): + with django_assert_num_queries(18): response = user_client.post(resource_url, data, follow=True) assert response.status_code == 200 assert response.redirect_chain[-1][0] == reverse("users:profile") @@ -69,7 +69,7 @@ def test_update_preferred_name_picture( ) -> None: """Test updating both preferred name and profile picture.""" data = {"preferred_name": "Jeff", "profile_picture": uploaded_file} - with django_assert_num_queries(14): + with django_assert_num_queries(18): response = user_client.post(resource_url, data, follow=True) assert response.status_code == 200 assert response.redirect_chain[-1][0] == reverse("users:profile") @@ -169,7 +169,7 @@ def test_set_password_success( """Test successfully setting a password.""" new_pw = "secure-password-123" data = {"new_password": new_pw, "new_password_confirm": new_pw} - with django_assert_num_queries(18): + with django_assert_num_queries(22): response = passwordless_user_client.post(resource_url, data) assert response.status_code == 302 passwordless_user.refresh_from_db() @@ -229,7 +229,7 @@ def test_with_correct_password( "new_password": "hello-world123", "new_password_confirm": "hello-world123", } - with django_assert_num_queries(20): + with django_assert_num_queries(24): response = user_client.post(resource_url, data, follow=True) assert response.status_code == 200, response.content assert response.wsgi_request.user.is_authenticated @@ -332,7 +332,7 @@ def test_happy_path( new_email = "new-email@example.com" data = {"new_email": new_email, "password": password} - with django_assert_num_queries(11): + with django_assert_num_queries(15): response = user_client.post(resource_url, data, follow=True) assert response.status_code == 200 @@ -425,7 +425,7 @@ def test_valid_token( args=(user_make_token(user=user, kind="update_email_address"),), ) - with django_assert_num_queries(12): + with django_assert_num_queries(17): response = user_client.get(resource_url, follow=True) assert response.status_code == 200 diff --git a/projectify/workspace/test/services/test_workspace.py b/projectify/workspace/test/services/test_workspace.py index 5efe7e1f4..cf01be136 100644 --- a/projectify/workspace/test/services/test_workspace.py +++ b/projectify/workspace/test/services/test_workspace.py @@ -5,7 +5,6 @@ from typing import cast -from django import db from django.core.files.uploadedfile import SimpleUploadedFile from django.db.models.fields.files import FileDescriptor from django.forms import ValidationError @@ -119,9 +118,7 @@ def test_add_user(workspace: Workspace, other_user: User) -> None: workspace=workspace, user=other_user, role=TeamMemberRoles.OBSERVER ) assert workspace.users.count() == count + 1 - # XXX TODO should be validationerror, not integrityerror - # We might get a bad 500 here, could be 400 instead - with pytest.raises(db.IntegrityError): + with pytest.raises(ValidationError): workspace_add_user( workspace=workspace, user=other_user, role=TeamMemberRoles.OBSERVER ) diff --git a/projectify/workspace/test/views/test_project.py b/projectify/workspace/test/views/test_project.py index 48ba98f3c..179f34853 100644 --- a/projectify/workspace/test/views/test_project.py +++ b/projectify/workspace/test/views/test_project.py @@ -35,7 +35,7 @@ def test_get_project_detail( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test GETting the project detail page.""" - with django_assert_num_queries(15): + with django_assert_num_queries(20): response = user_client.get(resource_url) assert response.status_code == 200 assert project.title in response.content.decode() @@ -103,6 +103,17 @@ def test_mark_task_done( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test marking a task as done and then not done.""" + # XXX non-deterministic test with either 30 or 31 queries + # This is because I've introduced full_clean to all BaseModel-derived + # save()s + # Workaround: Prevent assignee full_clean by assigning None + # Why does it work? conftes.tpy randomly assigns (faker.pybool()) + # an assignee to a task, or not. + # A better fix would be to prevent the project view in question here + # from trying to update any other column than the done status + # of a task + task.assignee = None + task.save() assert task.done is None t_id = str(task.uuid) data = {"action": "mark_task_done", "task_uuid": t_id, "done": "true"} @@ -112,14 +123,14 @@ def test_mark_task_done( # Gone down from 29 -> 28 # Gone down from 28 -> 26 # Gone down from 26 -> 22 - with django_assert_num_queries(22): + with django_assert_num_queries(30): response = user_client.post(resource_url, data) assert response.status_code == 200 task.refresh_from_db() assert task.done is not None data = {"action": "mark_task_done", "task_uuid": t_id, "done": "false"} - with django_assert_num_queries(22): + with django_assert_num_queries(30): response = user_client.post(resource_url, data) assert response.status_code == 200 task.refresh_from_db() @@ -171,7 +182,7 @@ def test_create_project_success( """Test successfully creating a project.""" initial_project_count = Project.objects.count() data = {"description": "

New Test Project

"} - with django_assert_num_queries(8): + with django_assert_num_queries(10): response = user_client.post(resource_url, data) assert response.status_code == 302 assert Project.objects.count() == initial_project_count + 1 @@ -231,7 +242,7 @@ def test_post_success( updated_title = "

Updated Project Title

foo bar

" data = {"description": updated_title} - with django_assert_num_queries(9): + with django_assert_num_queries(11): response = user_client.post(resource_url, data) assert response.status_code == 302 @@ -296,7 +307,7 @@ def test_post_archive_project( ) -> None: """Test successfully archiving a project via HTMX.""" assert not project.archived - with django_assert_num_queries(8): + with django_assert_num_queries(10): response = user_client.post(resource_url) assert response.status_code == 200 project.refresh_from_db() @@ -354,7 +365,7 @@ def test_post_recover_project( ) -> None: """Test successfully recovering an archived project via HTMX.""" assert archived_project.archived - with django_assert_num_queries(8): + with django_assert_num_queries(10): response = user_client.post(resource_url) assert response.status_code == 200 archived_project.refresh_from_db() diff --git a/projectify/workspace/test/views/test_task.py b/projectify/workspace/test/views/test_task.py index fdbf94b96..5a3a00f00 100644 --- a/projectify/workspace/test/views/test_task.py +++ b/projectify/workspace/test/views/test_task.py @@ -55,7 +55,7 @@ def test_create_task( t_uid = str(team_member.uuid) desc = "

Assigned Task

Bar

Qux

" data = {"description": desc, "assignee": t_uid, "action": "create"} - with django_assert_num_queries(11): + with django_assert_num_queries(15): response = user_client.post(resource_url, data) assert response.status_code == 302, response.content @@ -163,7 +163,7 @@ def test_update_task( desc = "

Updated Task Title

Rest

" t_uid = str(team_member.uuid) data = {"description": desc, "assignee": t_uid} - with django_assert_num_queries(12): + with django_assert_num_queries(16): response = user_client.post(resource_url, data) assert response.status_code == 302 task.refresh_from_db() diff --git a/projectify/workspace/test/views/test_workspace.py b/projectify/workspace/test/views/test_workspace.py index edced5605..2941cdc94 100644 --- a/projectify/workspace/test/views/test_workspace.py +++ b/projectify/workspace/test/views/test_workspace.py @@ -219,7 +219,7 @@ def test_update_workspace_and_title( # Query count went up from 24 -> 25 # Query count went down from 25 -> 24 # Query count went down from 24 -> 22 - with django_assert_num_queries(22): + with django_assert_num_queries(26): response = user_client.post( resource_url, { @@ -466,7 +466,7 @@ def test_update_team_member_role( other_team_member.job_title = "Developer" other_team_member.save() - with django_assert_num_queries(13): + with django_assert_num_queries(17): response = user_client.post( resource_url, {"role": TeamMemberRoles.MAINTAINER, "job_title": "Foo"}, @@ -741,7 +741,7 @@ def test_redeeming_valid_code( active = customer_check_active_for_workspace(workspace=workspace) assert active == "trial" data = {"action": "redeem_coupon", "code": coupon.code} - with django_assert_num_queries(17): + with django_assert_num_queries(22): response = user_client.post(resource_url, data=data) assert response.status_code == 302 From 2be6508b241fdc905a0668acd9b396afa33205f6 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 17:55:33 +0900 Subject: [PATCH 02/41] Blog: Use require_GET --- projectify/blog/tests/test_views.py | 14 +++++++++----- projectify/blog/views.py | 5 ++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/projectify/blog/tests/test_views.py b/projectify/blog/tests/test_views.py index 320327f67..0225b0f70 100644 --- a/projectify/blog/tests/test_views.py +++ b/projectify/blog/tests/test_views.py @@ -4,7 +4,7 @@ """Test blog views.""" from django.core.files.storage import default_storage -from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile +from django.core.files.uploadedfile import SimpleUploadedFile from django.test import Client from django.urls import reverse @@ -55,8 +55,10 @@ def test_post_draft_others_cant_view( post.draft = True post.save() url = reverse("blog:post_draft_preview", args=[post.slug]) + # Regular user response = user_client.get(url) assert response.status_code == 302 + # Not logged-in user response = client.get(url) assert response.status_code == 302 @@ -70,7 +72,9 @@ def test_post_detail_redirect(client: Client, post: Post) -> None: def test_uploading_attachments( - superuser_client: Client, uploaded_file: UploadedFile, png_image: bytes + superuser_client: Client, + uploaded_file: SimpleUploadedFile, + png_image: bytes, ) -> None: """Test that superuser can upload files and view them, too.""" url = reverse("blog:upload_attachment") @@ -86,7 +90,7 @@ def test_uploading_attachments( def test_upload_attachment_regular_user_cannot_upload( - user_client: Client, uploaded_file: UploadedFile + user_client: Client, uploaded_file: SimpleUploadedFile ) -> None: """Test that regular users cannot upload files.""" url = reverse("blog:upload_attachment") @@ -94,7 +98,7 @@ def test_upload_attachment_regular_user_cannot_upload( def test_upload_attachment_anonymous_user_cannot_upload( - client: Client, uploaded_file: UploadedFile + client: Client, uploaded_file: SimpleUploadedFile ) -> None: """Test that anonymous users cannot upload files.""" url = reverse("blog:upload_attachment") @@ -123,7 +127,7 @@ def test_post_feed_accessible(client: Client, post: Post) -> None: def test_serve_picture( - client: Client, uploaded_file: UploadedFile, png_image: bytes + client: Client, uploaded_file: SimpleUploadedFile, png_image: bytes ) -> None: """Test that serve_picture returns an uploaded file.""" # Missing file diff --git a/projectify/blog/views.py b/projectify/blog/views.py index f56c92e88..6779f8b2d 100644 --- a/projectify/blog/views.py +++ b/projectify/blog/views.py @@ -23,7 +23,7 @@ from django.shortcuts import render from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.views.decorators.http import require_http_methods +from django.views.decorators.http import require_GET, require_http_methods from django_sendfile import sendfile @@ -84,8 +84,11 @@ def sanitize_blog_picture_path(picture_name: str) -> Optional[str]: return str(Path("blog") / picture_name) +@require_GET def serve_picture(request: HttpRequest, name: str) -> HttpResponse: """Serve a blog picture using sendfile.""" + # XXX because the url pattern is serve-picture/ + # this can never contain slashes. We may not require the following: full_path = sanitize_blog_picture_path(name) if full_path is None or not default_storage.exists(str(full_path)): logger.warning("Picture with name %s not found at %s", name, full_path) From b31ec214fa13eba43e39edc855969ac3b7139450 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 18:06:11 +0900 Subject: [PATCH 03/41] Workspace: Add attachment upload (WIP) --- projectify/conftest.py | 10 +++ projectify/rules.py | 12 +++ projectify/test/test_rules.py | 2 + projectify/workspace/forms.py | 15 ++++ .../workspace/migrations/0086_attachment.py | 78 +++++++++++++++++ projectify/workspace/models.py | 50 ++++++++++- projectify/workspace/selectors/attachment.py | 19 +++++ projectify/workspace/selectors/quota.py | 25 ++++-- projectify/workspace/selectors/team_member.py | 17 +++- projectify/workspace/services/attachment.py | 57 +++++++++++++ .../test/services/test_attachment.py | 25 ++++++ .../workspace/test/views/test_attachment.py | 84 +++++++++++++++++++ projectify/workspace/types.py | 6 +- projectify/workspace/urls.py | 15 +++- projectify/workspace/views/attachment.py | 81 ++++++++++++++++++ projectify/workspace/views/task.py | 4 +- 16 files changed, 483 insertions(+), 17 deletions(-) create mode 100644 projectify/workspace/migrations/0086_attachment.py create mode 100644 projectify/workspace/selectors/attachment.py create mode 100644 projectify/workspace/services/attachment.py create mode 100644 projectify/workspace/test/services/test_attachment.py create mode 100644 projectify/workspace/test/views/test_attachment.py create mode 100644 projectify/workspace/views/attachment.py diff --git a/projectify/conftest.py b/projectify/conftest.py index 9489c7fc9..b3169da1e 100644 --- a/projectify/conftest.py +++ b/projectify/conftest.py @@ -54,6 +54,7 @@ user_invite_redeem, ) from projectify.workspace.models import ( + Attachment, Project, Task, TeamMember, @@ -64,6 +65,7 @@ from projectify.workspace.selectors.team_member import ( team_member_find_for_workspace, ) +from projectify.workspace.services.attachment import attachment_create from projectify.workspace.services.project import ( project_archive, project_create, @@ -463,6 +465,14 @@ def unrelated_task( ) +@pytest.fixture +def attachment( + team_member: TeamMember, uploaded_file: SimpleUploadedFile +) -> Attachment: + """Return an attachment uploaded by the normal user.""" + return attachment_create(who=team_member, file=uploaded_file) + + @pytest.fixture def unpaid_customer(workspace: Workspace) -> Customer: """Create customer.""" diff --git a/projectify/rules.py b/projectify/rules.py index 6c0bb8fd1..983b93c02 100644 --- a/projectify/rules.py +++ b/projectify/rules.py @@ -103,6 +103,9 @@ def can_create_more( ) # Return True if a team member invite can be sent for a workspace within_team_member_invite_quota = within_team_member_quota +within_attachment_quota = rules.predicate( + partial(can_create_more, "Attachment") +) # Workspace @@ -160,6 +163,15 @@ def can_edit_team_member(user: User, team_member: TeamMember) -> bool: rules.add_perm("workspace.update_task", is_at_least_contributor) rules.add_perm("workspace.delete_task", is_at_least_maintainer) +# Attachments +rules.add_perm( + "workspace.create_attachment", + is_at_least_contributor & within_attachment_quota, +) +rules.add_perm("workspace.read_attachment", is_at_least_observer) +rules.add_perm("workspace.update_attachment", is_at_least_contributor) +rules.add_perm("workspace.delete_attachment", is_at_least_maintainer) + # Customer rules.add_perm("corporate.can_create_customer", is_at_least_owner) rules.add_perm("corporate.can_read_customer", is_at_least_owner) diff --git a/projectify/test/test_rules.py b/projectify/test/test_rules.py index 6d2c3e4a1..aff0735f8 100644 --- a/projectify/test/test_rules.py +++ b/projectify/test/test_rules.py @@ -256,3 +256,5 @@ def test_team_member_and_invite_limit( workspace, raise_exception=False, ) + + # TODO test attachment quotas diff --git a/projectify/workspace/forms.py b/projectify/workspace/forms.py index 2a9a5e636..799065167 100644 --- a/projectify/workspace/forms.py +++ b/projectify/workspace/forms.py @@ -11,6 +11,8 @@ from django.db.models import Model, QuerySet from django.utils.translation import gettext_lazy as _ +from projectify.lib.forms import SafeImageField +from projectify.lib.settings import get_settings from projectify.workspace.models import TeamMember @@ -120,3 +122,16 @@ def clean(self) -> dict[str, Any]: ) ) return cleaned_data + + +class AttachmentUploadForm(forms.Form): + """Form for uploading project or task attachments.""" + + def __init__(self, *args: Any, **kwargs: Any): + """Initialize form with SafeImageField configured from settings.""" + super().__init__(*args, **kwargs) + settings = get_settings() + self.fields["file"] = SafeImageField( + allowed_file_types=settings.BLOG_ALLOWED_FILE_TYPES, + allowed_file_size=settings.BLOG_ALLOWED_FILE_SIZE, + ) diff --git a/projectify/workspace/migrations/0086_attachment.py b/projectify/workspace/migrations/0086_attachment.py new file mode 100644 index 000000000..98ccb65e9 --- /dev/null +++ b/projectify/workspace/migrations/0086_attachment.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Create Attachment model.""" +# Generated by Django 6.0.5 on 2026-07-02 09:03 + +import django.db.models.deletion +from django.db import migrations, models + +import projectify.lib.models + + +class Migration(migrations.Migration): + """Migration.""" + + dependencies = [ + ("workspace", "0085_remove_teammember_minimized_project_list_and_more") + ] + + operations = [ + migrations.CreateModel( + name="Attachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + projectify.lib.models.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + projectify.lib.models.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "name", + models.CharField( + db_index=True, + help_text="Attachment file name", + max_length=512, + unique=True, + ), + ), + ( + "size", + models.PositiveIntegerField( + help_text="Attachment size in bytes" + ), + ), + ( + "uploader", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="workspace.teammember", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="workspace.workspace", + ), + ), + ], + ) + ] diff --git a/projectify/workspace/models.py b/projectify/workspace/models.py index d5d2a0d35..454f9d8b6 100644 --- a/projectify/workspace/models.py +++ b/projectify/workspace/models.py @@ -5,6 +5,7 @@ import logging import uuid +from pathlib import Path from typing import TYPE_CHECKING, Any, Optional from django.conf import settings @@ -69,6 +70,7 @@ def __init__(self, *args: Any, **kwargs: Any): task_set: RelatedManager["Task"] project_set: RelatedManager["Project"] teammember_set: RelatedManager["TeamMember"] + attachment_set: RelatedManager["Attachment"] teammemberinvite_set: RelatedManager["TeamMemberInvite"] active_invites: Optional[RelatedManager["TeamMemberInvite"]] @@ -284,9 +286,55 @@ class Meta: ordering = ("created",) +class Attachment(BaseModel): + """Workspace file attachment.""" + + name = models.CharField( + max_length=512, + unique=True, + db_index=True, + help_text=_("Attachment file name"), + ) + size = models.PositiveIntegerField(help_text=_("Attachment size in bytes")) + uploader = models.ForeignKey["TeamMember"]( + TeamMember, on_delete=models.SET_NULL, editable=False, null=True + ) + workspace = models.ForeignKey[Workspace]( + Workspace, on_delete=models.CASCADE + ) + # TODO store a cryptographic digest here to help users deduplicate + # file uploads and save on storage + + class Meta: + """Meta.""" + + def get_absolute_url(self) -> str: + """ + Return URL for direct viewing. + + Direct means that Projectify returns the file as-is and not as + part of an attachment edit form or similar. + """ + return reverse( + "dashboard:attachments:view", args=(self.workspace.uuid, self.name) + ) + + @property + def storage_path(self) -> Path: + """Return full storage path for attachment.""" + # helloworld.png -> + # workspace/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/attachments/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.hellowor.png/ + return ( + Path("workspace") + / str(self.workspace.uuid) + / "attachments" + / self.name + ) + + +# TODO remove __all__ = ( "Project", - # TODO remove "Task", "TeamMember", "TeamMemberInvite", diff --git a/projectify/workspace/selectors/attachment.py b/projectify/workspace/selectors/attachment.py new file mode 100644 index 000000000..d2c111d94 --- /dev/null +++ b/projectify/workspace/selectors/attachment.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Attachment selectors.""" + +from typing import Optional +from uuid import UUID + +from projectify.user.models import User + +from ..models import Attachment + + +def attachment_find_by_workspace_uuid_and_name( + *, who: User, workspace_uuid: UUID, name: str +) -> Optional[Attachment]: + """Find attachment for user and workspace.""" + return Attachment.objects.filter( + workspace__uuid=workspace_uuid, workspace__users=who, name=name + ).first() diff --git a/projectify/workspace/selectors/quota.py b/projectify/workspace/selectors/quota.py index b0ff60901..c3a2929a5 100644 --- a/projectify/workspace/selectors/quota.py +++ b/projectify/workspace/selectors/quota.py @@ -13,6 +13,8 @@ from functools import partial from typing import Literal, TypedDict, Union +from django.db.models import Sum + from projectify.corporate.selectors.customer import ( customer_check_active_for_workspace, ) @@ -21,7 +23,7 @@ from ..models import Task, Workspace -Resource = Literal["Task", "Project", "TeamMemberAndInvite"] +Resource = Literal["Task", "Project", "TeamMemberAndInvite", "Attachment"] Limitation = Union[None, int] @@ -32,12 +34,15 @@ class Limitations(TypedDict): Task: Limitation Project: Limitation TeamMemberAndInvite: Limitation + Attachment: Limitation trial_conditions: Limitations = { "Task": 1000, "Project": 10, "TeamMemberAndInvite": 2, + # No attachments for trial + "Attachment": 0, } # Full workspace conditions are somewhat like this: @@ -58,10 +63,11 @@ def get_workspace_quota_for_resource( """ if get_settings().STRIPE_CONFIG is None: return None - status = customer_check_active_for_workspace(workspace=workspace) - # We regard inactive as trial - if status in ["trial", "inactive"]: - return trial_conditions[resource] + match customer_check_active_for_workspace(workspace=workspace): + case "trial" | "inactive": + return trial_conditions[resource] + case "full": + pass if resource == "TeamMemberAndInvite": customer = workspace.customer return customer.seats @@ -83,6 +89,14 @@ def get_workspace_resource_count( redeemed=False ).count() return user_count + invite_count + case "Attachment": + match workspace.attachment_set.aggregate(total_size=Sum("size")): + case {"total_size": int() as result}: + return result + case other: + raise RuntimeError( + f"Encountered unexpected result {other}" + ) def workspace_quota_for(*, resource: Resource, workspace: Workspace) -> Quota: @@ -105,4 +119,5 @@ def workspace_get_all_quotas(workspace: Workspace) -> WorkspaceQuota: tasks=mk(resource="Task"), projects=mk(resource="Project"), team_members_and_invites=mk(resource="TeamMemberAndInvite"), + attachments=mk(resource="Attachment"), ) diff --git a/projectify/workspace/selectors/team_member.py b/projectify/workspace/selectors/team_member.py index fa200ed4c..e8c74f545 100644 --- a/projectify/workspace/selectors/team_member.py +++ b/projectify/workspace/selectors/team_member.py @@ -21,13 +21,28 @@ def team_member_find_for_workspace( return None +def team_member_find_by_workspace_uuid( + *, who: User, workspace_uuid: UUID +) -> Optional[TeamMember]: + """Find team member by workspace UUID.""" + try: + return TeamMember.objects.select_related("user").get( + user=who, workspace__uuid=workspace_uuid + ) + except TeamMember.DoesNotExist: + return None + + def team_member_find_by_team_member_uuid( *, who: User, team_member_uuid: UUID ) -> Optional[TeamMember]: """Find team member by UUID according to user access permissions.""" try: return TeamMember.objects.select_related("user").get( - workspace__users=who, uuid=team_member_uuid + # XXX make this + # user=who, uuid=team_member_uuid + workspace__users=who, + uuid=team_member_uuid, ) except TeamMember.DoesNotExist: return None diff --git a/projectify/workspace/services/attachment.py b/projectify/workspace/services/attachment.py new file mode 100644 index 000000000..98ee8558e --- /dev/null +++ b/projectify/workspace/services/attachment.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Attachment services.""" + +import logging +from pathlib import Path +from uuid import uuid4 + +from django.core.files.storage import default_storage +from django.core.files.uploadedfile import UploadedFile +from django.db import transaction + +from projectify.lib.auth import validate_perm +from projectify.workspace.selectors.team_member import ( + team_member_find_for_workspace, +) + +from ..models import Attachment, TeamMember + +logger = logging.getLogger(__name__) + + +def attachment_create(*, who: TeamMember, file: UploadedFile) -> Attachment: + """ + Create an attachment. + + CAVEAT: Does not perform file content validation. + """ + validate_perm("workspace.create_attachment", who.user, who.workspace) + attachment_name = Path(file.name) + # helloworld.png -> XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.hellowor.png + upload_name = ( + f"{uuid4()}.{attachment_name.name[:8]}{attachment_name.suffix}" + ) + team_member = team_member_find_for_workspace( + user=who.user, workspace=who.workspace + ) + try: + with transaction.atomic(): + attachment = Attachment.objects.create( + name=upload_name, + size=file.size, + workspace=who.workspace, + uploader=team_member, + ) + attachment.save() + + # Putting .save() last means we roll back and not save any + # attachment when saving this file to storage fails + default_storage.save(str(attachment.storage_path), file) + except Exception as e: + e.add_note( + f"Couldn't upload attachment with size {file.size} to " + f"workspace {who.workspace.uuid}" + ) + raise e + return attachment diff --git a/projectify/workspace/test/services/test_attachment.py b/projectify/workspace/test/services/test_attachment.py new file mode 100644 index 000000000..6e91fec3f --- /dev/null +++ b/projectify/workspace/test/services/test_attachment.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Test attachment services.""" + +from django.core.files.storage import default_storage +from django.core.files.uploadedfile import SimpleUploadedFile + +import pytest + +from projectify.workspace.services.attachment import attachment_create + +from ...models import TeamMember + +pytestmark = pytest.mark.django_db + +# TODO test path traversal here +# TODO test quota here + + +def test_attachment_create( + team_member: TeamMember, uploaded_file: SimpleUploadedFile +) -> None: + """Test that the attachment_create services stores the attachment.""" + attachment = attachment_create(who=team_member, file=uploaded_file) + assert default_storage.exists(str(attachment.storage_path)) diff --git a/projectify/workspace/test/views/test_attachment.py b/projectify/workspace/test/views/test_attachment.py new file mode 100644 index 000000000..893aa1f3b --- /dev/null +++ b/projectify/workspace/test/views/test_attachment.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Test attachment views.""" + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client +from django.urls import reverse + +import pytest + +from ...models import Attachment, TeamMember + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def create_url(team_member: TeamMember) -> str: + """Return this view's URL.""" + ws_uuid = team_member.workspace.uuid + return reverse("dashboard:attachments:create", args=(ws_uuid,)) + + +def test_upload( + user_client: Client, + uploaded_file: SimpleUploadedFile, + png_image: bytes, + create_url: str, +) -> None: + """Test uploading an attachment.""" + response = user_client.post(create_url, {"file": uploaded_file}) + assert response.status_code == 201, response.content + data = response.json() + assert "url" in data + + serve_response = user_client.get(data["url"]) + assert serve_response.status_code == 200 + assert serve_response.content == png_image + + +def test_view_authorized( + user_client: Client, attachment: Attachment, png_image: bytes +) -> None: + """Test viewing as an authorized user.""" + serve_response = user_client.get(attachment.get_absolute_url()) + assert serve_response.status_code == 200 + assert serve_response.content == png_image + + +def test_view_bad_path( + user_client: Client, attachment: Attachment, png_image: bytes +) -> None: + """Test viewing as an authorized user.""" + url = reverse( + "dashboard:attachments:view", + args=(attachment.workspace.uuid, "wrong-name.png"), + ) + serve_response = user_client.get(url) + assert serve_response.status_code == 404 + assert serve_response.content != png_image + + +def test_view_deleted_attachment( + user_client: Client, attachment: Attachment, png_image: bytes +) -> None: + """Test viewing as an authorized user.""" + url = attachment.get_absolute_url() + attachment.delete() + serve_response = user_client.get(url) + assert serve_response.status_code == 404 + assert serve_response.content != png_image + + +def test_view_unauthorized( + unrelated_user_client: Client, attachment: Attachment, png_image: bytes +) -> None: + """Test what happens when you try to view files from another ws.""" + not_found_response = unrelated_user_client.get( + attachment.get_absolute_url() + ) + assert not_found_response.status_code == 404 + assert not_found_response.content != png_image + + +# TODO test path traversal diff --git a/projectify/workspace/types.py b/projectify/workspace/types.py index 93b6b1dbc..897c6dde7 100644 --- a/projectify/workspace/types.py +++ b/projectify/workspace/types.py @@ -4,7 +4,7 @@ """Shared type definitions in workspace app.""" from dataclasses import dataclass -from typing import Literal, Optional +from typing import Optional from projectify.corporate.types import WorkspaceFeatures @@ -28,6 +28,4 @@ class WorkspaceQuota: tasks: Quota projects: Quota team_members_and_invites: Quota - - -Resource = Literal["workspace", "project", "task"] + attachments: Quota diff --git a/projectify/workspace/urls.py b/projectify/workspace/urls.py index 74622720a..546385b9f 100644 --- a/projectify/workspace/urls.py +++ b/projectify/workspace/urls.py @@ -10,6 +10,10 @@ from projectify.lib.settings import get_settings from projectify.lib.types import UrlPatterns +from projectify.workspace.views.attachment import ( + attachment_create_view, + attachment_view, +) from projectify.workspace.views.avatar_marble import avatar_marble_view from projectify.workspace.views.dashboard import redirect_to_dashboard from projectify.workspace.views.project import ( @@ -145,6 +149,12 @@ "/picture", team_member_picture, name="picture" ), ) +attachment_patterns = ( + path("/attachments", attachment_create_view, name="create"), + path( + "/attachments/", attachment_view, name="view" + ), +) urlpatterns = ( path("", redirect_to_dashboard, name="dashboard"), # Avatar @@ -153,12 +163,9 @@ avatar_marble_view, name="avatar-marble", ), - # Workspace path("workspace/", include((workspace_patterns, "workspaces"))), - # Project path("project/", include((project_patterns, "projects"))), - # Task path("task/", include((task_patterns, "tasks"))), - # Team member path("team-member/", include((team_member_patterns, "team-members"))), + path("workspace/", include((attachment_patterns, "attachments"))), ) diff --git a/projectify/workspace/views/attachment.py b/projectify/workspace/views/attachment.py new file mode 100644 index 000000000..b28c9a401 --- /dev/null +++ b/projectify/workspace/views/attachment.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Workspace attachment views.""" + +import logging +from uuid import UUID + +from django.core.files.storage import default_storage +from django.core.files.uploadedfile import UploadedFile +from django.forms.utils import ErrorList +from django.http import Http404, HttpResponse, JsonResponse +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_GET, require_POST + +from django_sendfile import sendfile + +from projectify.lib.types import AuthenticatedHttpRequest +from projectify.lib.views import platform_view +from projectify.workspace.selectors.attachment import ( + attachment_find_by_workspace_uuid_and_name, +) +from projectify.workspace.selectors.team_member import ( + team_member_find_by_workspace_uuid, +) +from projectify.workspace.services.attachment import attachment_create + +from ..forms import AttachmentUploadForm + +logger = logging.getLogger(__name__) + + +@platform_view +@require_POST +def attachment_create_view( + request: AuthenticatedHttpRequest, ws_uuid: UUID +) -> HttpResponse: + """Upload an image attachment to a workspace.""" + team_member = team_member_find_by_workspace_uuid( + workspace_uuid=ws_uuid, who=request.user + ) + if team_member is None: + raise Http404( + _( + "Could not find workspace with UUID {workspace_uuid} for current user" + ).format(workspace_uuid=ws_uuid) + ) + form = AttachmentUploadForm(request.POST, request.FILES) + if not form.is_valid(): + match form.errors.get("file"): + case None: + error: str | list[dict[str, str]] = _("No error message") + case ErrorList() as e: + error = e.get_json_data() + return JsonResponse({"error": error}, status=400) + + file: UploadedFile = form.cleaned_data["file"] + attachment = attachment_create(who=team_member, file=file) + url = attachment.get_absolute_url() + return JsonResponse({"url": url}, status=201) + + +@platform_view +@require_GET +def attachment_view( + request: AuthenticatedHttpRequest, ws_uuid: UUID, name: str +) -> HttpResponse: + """Retrieve an attachment with sendfile().""" + attachment = attachment_find_by_workspace_uuid_and_name( + who=request.user, name=name, workspace_uuid=ws_uuid + ) + if attachment is None: + raise Http404( + _("Could not find workspace with UUID {workspace_uuid}").format( + workspace_uuid=ws_uuid + ) + ) + # this view could check whether the file exists. An Attachment record + # existing but the file not being there is unexpected so I'd rather + # let it crash hard + file_path = default_storage.path(str(attachment.storage_path)) + return sendfile(request, file_path) diff --git a/projectify/workspace/views/task.py b/projectify/workspace/views/task.py index 991ae73ae..9f822049b 100644 --- a/projectify/workspace/views/task.py +++ b/projectify/workspace/views/task.py @@ -14,7 +14,7 @@ from django.shortcuts import redirect, render from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.views.decorators.http import require_http_methods +from django.views.decorators.http import require_http_methods, require_POST from projectify.lib.forms import RichTextEditor from projectify.lib.htmx import ( @@ -287,7 +287,7 @@ def task_update_view( return render(request, template, context, status=status) -# TODO require POST +@require_POST def task_delete_view( request: AuthenticatedHttpRequest, task_uuid: UUID ) -> HttpResponse: From 663463b89676eeb9c88c5b14dd1b0553309f6d30 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 18:19:00 +0900 Subject: [PATCH 04/41] Workspace: Fix quota query counts --- projectify/workspace/selectors/quota.py | 4 +++- .../workspace/test/views/test_workspace.py | 20 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/projectify/workspace/selectors/quota.py b/projectify/workspace/selectors/quota.py index c3a2929a5..0ea3d141e 100644 --- a/projectify/workspace/selectors/quota.py +++ b/projectify/workspace/selectors/quota.py @@ -90,7 +90,9 @@ def get_workspace_resource_count( ).count() return user_count + invite_count case "Attachment": - match workspace.attachment_set.aggregate(total_size=Sum("size")): + match workspace.attachment_set.aggregate( + total_size=Sum("size", default=0) + ): case {"total_size": int() as result}: return result case other: diff --git a/projectify/workspace/test/views/test_workspace.py b/projectify/workspace/test/views/test_workspace.py index 2941cdc94..292570493 100644 --- a/projectify/workspace/test/views/test_workspace.py +++ b/projectify/workspace/test/views/test_workspace.py @@ -351,8 +351,7 @@ def test_remove_team_member( data = {"action": "team_member_remove", "team_member": uid} # Gone down from 28 -> 25 # Gone down from 25 -> 23 - # Gone down from 23 -> 22 - with django_assert_num_queries(22): + with django_assert_num_queries(23): response = user_client.post(resource_url, data) assert response.status_code == 200 assert workspace.teammember_set.count() == initial - 1 @@ -517,8 +516,8 @@ def test_get_quota_page_no_subscription( customer_cancel_subscription(customer=team_member.workspace.customer) # Gone up from 16 -> 17 due to permission checks in sidemenu # Gone down from 17 -> 15 - # Gone down from 15 -> 13 - with django_assert_num_queries(13): + # Gone down from 15 -> 14 + with django_assert_num_queries(14): response = user_client.get(resource_url) assert response.status_code == 200 # These quotas should be listed @@ -567,7 +566,7 @@ def test_with_unpaid_customer( ) -> None: """Assert that an unpaid customer can't edit their billing settings.""" data = {"action": "checkout", "seats": 5} - with django_assert_num_queries(16): + with django_assert_num_queries(17): response = user_client.post(resource_url, data=data) assert response.status_code == 302 assert response.headers["Location"] == "https://www.example.com" @@ -614,7 +613,7 @@ def test_posting_normal_data( ) -> None: """Test we can get a redirect when posting valid checkout data.""" data = {"action": "checkout", "seats": "99"} - with django_assert_num_queries(16): + with django_assert_num_queries(17): response = user_client.post(resource_url, data=data) assert response.status_code == 302, response.content.decode() assert response.headers["Location"] == "https://www.example.com" @@ -669,8 +668,8 @@ def test_get_with_unpaid_customer( """Test GET request with unpaid customer shows billing form.""" # Gone up from 16 -> 17 due to permission checks in sidemenu # Gone down from 17 -> 15 - # Gone down from 15 -> 13 - with django_assert_num_queries(13): + # Gone down from 15 -> 14 + with django_assert_num_queries(14): response = user_client.get(resource_url) assert response.status_code == 200 assert b"Use a coupon code" in response.content @@ -717,8 +716,7 @@ def test_redeeming_invalid_code( # Gone up from 22 -> 23 # Gone down from 23 -> 19 # Gone down from 19 -> 18 - # Gone down from 18 -> 17 - with django_assert_num_queries(17): + with django_assert_num_queries(18): res = user_client.post(resource_url, data=data) assert res.status_code == 400 assert "No coupon is available for this code" in res.content.decode() @@ -741,7 +739,7 @@ def test_redeeming_valid_code( active = customer_check_active_for_workspace(workspace=workspace) assert active == "trial" data = {"action": "redeem_coupon", "code": coupon.code} - with django_assert_num_queries(22): + with django_assert_num_queries(23): response = user_client.post(resource_url, data=data) assert response.status_code == 302 From 4d38ff63146cc984f273077bc3ca8e1f1be83eb6 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 18:19:39 +0900 Subject: [PATCH 05/41] Workspace: Add upload URL to project/task editor --- projectify/workspace/views/project.py | 41 +++++++++++++----------- projectify/workspace/views/task.py | 45 ++++++++++++++------------- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/projectify/workspace/views/project.py b/projectify/workspace/views/project.py index b5a1f436d..2233cff90 100644 --- a/projectify/workspace/views/project.py +++ b/projectify/workspace/views/project.py @@ -209,29 +209,34 @@ def project_detail_view( class ProjectForm(forms.Form): """Form for project creation.""" - description = forms.CharField( - label=_("Description"), - widget=RichTextEditor( - heading_blocks=False, - attrs={"expand": True, "class": TASK_EDITOR_MIN_HEIGHT_CLASS}, - ), - ) + description = forms.CharField(label=_("Description")) def __init__(self, *args: Any, workspace: Workspace, **kwargs: Any): """Populate available assignees and optionally set autofocus.""" super().__init__(*args, **kwargs) - self.fields["description"].widget.attrs["data-suggest-links-url"] = ( - reverse( - "dashboard:workspaces:suggest-links-task", - args=(workspace.uuid,), - ) - ) - self.fields["description"].widget.attrs[ - "data-suggest-projects-url" - ] = reverse( - "dashboard:workspaces:suggest-links-project", - args=(workspace.uuid,), + # XXX duplicated from projectify/workspace/views/task.py:TaskForm + editor = RichTextEditor( + heading_blocks=False, + upload_url=reverse( + "dashboard:attachments:create", args=(workspace.uuid,) + ), + attrs={ + "expand": True, + "placeholder": _("Enter a description for your task"), + "class": TASK_EDITOR_MIN_HEIGHT_CLASS, + "data-suggest-projects-url": reverse( + "dashboard:workspaces:suggest-links-project", + args=(workspace.uuid,), + ), + "data-suggest-links-url": ( + reverse( + "dashboard:workspaces:suggest-links-task", + args=(workspace.uuid,), + ), + ), + }, ) + self.fields["description"].widget = editor @require_http_methods(["GET", "POST"]) diff --git a/projectify/workspace/views/task.py b/projectify/workspace/views/task.py index 9f822049b..46a9a6cdd 100644 --- a/projectify/workspace/views/task.py +++ b/projectify/workspace/views/task.py @@ -80,17 +80,8 @@ class TaskForm(forms.Form): label=_("Due date"), widget=forms.DateTimeInput(attrs={"type": "date"}), ) - description = forms.CharField( - label=_("Description"), - widget=RichTextEditor( - heading_blocks=False, - attrs={ - "expand": True, - "placeholder": _("Enter a description for your task"), - "class": TASK_EDITOR_MIN_HEIGHT_CLASS, - }, - ), - ) + + description = forms.CharField(label=_("Description")) def clean_description(self) -> str: """Make sure that the description has at least one child.""" @@ -127,18 +118,28 @@ def __init__( ) self.order_fields(["description", "assignee", "due_date"]) - self.fields["description"].widget.attrs["data-suggest-links-url"] = ( - reverse( - "dashboard:workspaces:suggest-links-task", - args=(workspace.uuid,), - ) - ) - self.fields["description"].widget.attrs[ - "data-suggest-projects-url" - ] = reverse( - "dashboard:workspaces:suggest-links-project", - args=(workspace.uuid,), + editor = RichTextEditor( + heading_blocks=False, + upload_url=reverse( + "dashboard:attachments:create", args=(workspace.uuid,) + ), + attrs={ + "expand": True, + "placeholder": _("Enter a description for your task"), + "class": TASK_EDITOR_MIN_HEIGHT_CLASS, + "data-suggest-projects-url": reverse( + "dashboard:workspaces:suggest-links-project", + args=(workspace.uuid,), + ), + "data-suggest-links-url": ( + reverse( + "dashboard:workspaces:suggest-links-task", + args=(workspace.uuid,), + ), + ), + }, ) + self.fields["description"].widget = editor if focus_field is None: pass From 884289c4439de17ce3ca829a9a99157b51aebfbd Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 18:36:16 +0900 Subject: [PATCH 06/41] Workspace: Hide attachments behind feature flag --- projectify/settings/base.py | 10 +++++++++- projectify/settings/development.py | 4 +++- projectify/settings/test.py | 4 +++- projectify/settings/types.py | 12 ++++++++++++ projectify/workspace/urls.py | 11 +++++++++-- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/projectify/settings/base.py b/projectify/settings/base.py index 535fe3730..1ae37790c 100644 --- a/projectify/settings/base.py +++ b/projectify/settings/base.py @@ -29,6 +29,7 @@ from .monkeypatch import patch from .types import ( + FeatureFlags, SocialAccountProvider, StoragesConfig, StripeConfig, @@ -64,6 +65,12 @@ class Base(Configuration): See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ """ + # Feature flags + # ============= + # This tracks which features Projectify should activate + # For development -related features, see "Debug flags" below + FEATURE_FLAGS = FeatureFlags(workspace_attachments=False) + SECRET_KEY: str # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -75,7 +82,8 @@ class Base(Configuration): # SECURITY WARNING: don't run with debug turned on in production! ALLOWED_HOSTS: Sequence[str] = [] - # Debug + # Debug flags + # =========== # Should Django run in debug mode? DEBUG = False # Should Projectify render the Django debug toolbar? diff --git a/projectify/settings/development.py b/projectify/settings/development.py index 2bf3c1c60..589bb4d89 100644 --- a/projectify/settings/development.py +++ b/projectify/settings/development.py @@ -9,7 +9,7 @@ import dj_database_url -from .types import StripeConfig +from .types import FeatureFlags, StripeConfig try: from dotenv import load_dotenv @@ -44,6 +44,8 @@ def add_dev_middleware( class Development(Base): """Development configuration.""" + FEATURE_FLAGS = FeatureFlags(workspace_attachments=True) + SITE_TITLE = "Local Development" SECRET_KEY = "development" diff --git a/projectify/settings/test.py b/projectify/settings/test.py index f63804530..0fab17136 100644 --- a/projectify/settings/test.py +++ b/projectify/settings/test.py @@ -10,7 +10,7 @@ from faker import Faker -from projectify.settings.types import StripeConfig +from .types import FeatureFlags, StripeConfig try: from dotenv import load_dotenv @@ -27,6 +27,8 @@ class Test(Base): """Test configuration.""" + FEATURE_FLAGS = FeatureFlags(workspace_attachments=True) + SITE_TITLE = "Projectify Pytest" MIDDLEWARE = [ diff --git a/projectify/settings/types.py b/projectify/settings/types.py index 40ace3261..468709100 100644 --- a/projectify/settings/types.py +++ b/projectify/settings/types.py @@ -58,6 +58,18 @@ class StripeConfig: # XXX it doesn't look like Projectify is using the publishable key # TODO consider removing the STRIPE_PUBLISHABLE_KEY STRIPE_PUBLISHABLE_KEY: str + # TODO lower case the following three fields + # stripe_secret_key: str STRIPE_SECRET_KEY: str + # stripe_endpoint_secret: str STRIPE_ENDPOINT_SECRET: str + # stripe_price_object: str STRIPE_PRICE_OBJECT: str + + +@dataclass(frozen=True, kw_only=True) +class FeatureFlags: + """Projectify feature flags.""" + + """Set to True to Enable workspace attachments.""" + workspace_attachments: bool diff --git a/projectify/workspace/urls.py b/projectify/workspace/urls.py index 546385b9f..2b048efe4 100644 --- a/projectify/workspace/urls.py +++ b/projectify/workspace/urls.py @@ -47,6 +47,9 @@ logger = logging.getLogger(__name__) + +settings = get_settings() + # TODO rename to workspace # app_name = "workspace" app_name = "dashboard" @@ -155,7 +158,7 @@ "/attachments/", attachment_view, name="view" ), ) -urlpatterns = ( +urlpatterns: UrlPatterns = ( path("", redirect_to_dashboard, name="dashboard"), # Avatar path( @@ -167,5 +170,9 @@ path("project/", include((project_patterns, "projects"))), path("task/", include((task_patterns, "tasks"))), path("team-member/", include((team_member_patterns, "team-members"))), - path("workspace/", include((attachment_patterns, "attachments"))), ) +if settings.FEATURE_FLAGS.workspace_attachments: + urlpatterns = ( + *urlpatterns, + path("workspace/", include((attachment_patterns, "attachments"))), + ) From 856f1fe303a2813fd8c098499c913c477a66c5fe Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 18:36:31 +0900 Subject: [PATCH 07/41] Mark TODOs --- projectify/lib/utils.py | 2 ++ projectify/settings/types.py | 2 ++ projectify/workspace/test/views/test_attachment.py | 2 ++ projectify/workspace/urls.py | 1 - 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/projectify/lib/utils.py b/projectify/lib/utils.py index 328a2919e..915ea3b30 100644 --- a/projectify/lib/utils.py +++ b/projectify/lib/utils.py @@ -32,6 +32,8 @@ def clean_rich_text( sanitized_html: str = JustHTML( unsafe_html, policy=policy, fragment=True ).to_html(pretty=False) + # TODO strip empty blocks + # TODO strip repeated whitespace " " -> " " # Remember that just marking it "safe" doesn't make it safe # sanitized_html is safe to mark as "safe" because `JustHTML` has # cleaned it. diff --git a/projectify/settings/types.py b/projectify/settings/types.py index 468709100..c9bd4bc42 100644 --- a/projectify/settings/types.py +++ b/projectify/settings/types.py @@ -51,6 +51,8 @@ class StorageConfig(TypedDict): ) +# TODO +# @dataclass(freeze=True, kw_only=True) @dataclass class StripeConfig: """Hold configuration needed to use Stripe.""" diff --git a/projectify/workspace/test/views/test_attachment.py b/projectify/workspace/test/views/test_attachment.py index 893aa1f3b..9e78170b8 100644 --- a/projectify/workspace/test/views/test_attachment.py +++ b/projectify/workspace/test/views/test_attachment.py @@ -82,3 +82,5 @@ def test_view_unauthorized( # TODO test path traversal +# TODO test upload file size limits +# TODO test file type validation diff --git a/projectify/workspace/urls.py b/projectify/workspace/urls.py index 2b048efe4..7ffb27447 100644 --- a/projectify/workspace/urls.py +++ b/projectify/workspace/urls.py @@ -2,7 +2,6 @@ # # SPDX-FileCopyrightText: 2024 JWP Consulting GK """Workspace URLs for dashboard.""" -# TODO rename to projectify.workspace.urls import logging From 0a2627023745ceb5ebca3a5104ce51395c133bee Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 18:47:20 +0900 Subject: [PATCH 08/41] Help: Update quota article --- projectify/help/markdown_en/quota.md | 40 +++++++++++++++------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/projectify/help/markdown_en/quota.md b/projectify/help/markdown_en/quota.md index a071e1379..47965572f 100644 --- a/projectify/help/markdown_en/quota.md +++ b/projectify/help/markdown_en/quota.md @@ -4,43 +4,45 @@ SPDX-FileCopyrightText: 2024 JWP Consulting GK SPDX-License-Identifier: AGPL-3.0-or-later --> -# What are quotas +# What are usage quotas -Depending on whether you have a paid workspace or a trial workspace, different -usage quotas will apply. Usage quotas determine how many items of a resouce you +Different usage quotas apply depending on whether you have a paid or a +[trial workspace](/help/trial). +Usage quotas determine how many items of a resource you can create within your workspace. Resources within your workspace are: - Team members and invitations - Projects - Tasks +- Attachments # Quotas for paid workspaces -The only quota that applies to a paid workspace is how many team members you -can add and how many pending invites for new team members you have. These -two values are added together and compared with the number of seats you have -remaining in your workspace. For example, if you have a workspace with 10 -seats, then +Paid workspaces have the following usage quotas: -- if you have 8 team members and 1 pending invitation, you can invite or add - one more team member -- if you have 8 team members and 2 pending invitations, you can not invite - or add any more team members +- You can invite team members as long as your workspace has empty seats. + Pending invitations take up seats, too. Example: If your workspace has 10 + seats, your workspace can host 8 team members and have 2 pending + invitiations. +- You can upload up to 100 MiB in attachments in total. Example: If you +have 2 x 40 MiB attachments your total is 80 MiB. You can then upload +one 20 MiB attachment. -If you would like to add more seats to your workspace, please review your -workspace billing settings in the dashboard. For more information on how to -change the billing settings, please refer to the [billing help](/help/billing). +To add more seats to your workspace, update your +workspace billing settings. To learn how to +update your workspace biling settings, refer to the [billing help](/help/billing). -# Quotas for trial workspaces +# Usage quotas for trial workspaces -In a trial workspace, the following quotas apply: +Trial workspaces have the following usage quotas: - You can invite and add up to 1 additional team member, for a total of 2 including yourself. - You can create up to 10 projects - You can create up to 1000 tasks +- You can not upload any attachments -If you would like to create more items, and invite more users to your -workspace, you can upgrade to a paid workspace from the workspace billing +To create more items, invite more users, or upload attachments to your +workspace, upgrade to a paid workspace from the workspace billing settings. For more information on how to upgrade to paid workspace, please refer to the [billing help](/help/billing). From 18cbb110d6a61c2de2c09fd5f174f708c2bcc1f2 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 19:01:14 +0900 Subject: [PATCH 09/41] Simplify base layouts --- projectify/blog/templates/blog/blog_base.html | 4 +--- .../socialaccount/base_entrance.html | 3 --- .../templates/socialaccount/base_manage.html | 3 --- .../user/templates/user_profile_base.html | 21 ++++++++----------- 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/projectify/blog/templates/blog/blog_base.html b/projectify/blog/templates/blog/blog_base.html index f25c509c9..06e4dde4a 100644 --- a/projectify/blog/templates/blog/blog_base.html +++ b/projectify/blog/templates/blog/blog_base.html @@ -5,9 +5,7 @@ {% extends "base.html" %} {% block body %}
- {% block storefront_header %} - {% include "common/navigation/header/landing.html" %} - {% endblock storefront_header %} + {% include "common/navigation/header/landing.html" %}
{% block blog_content %} {% endblock blog_content %} diff --git a/projectify/user/templates/socialaccount/base_entrance.html b/projectify/user/templates/socialaccount/base_entrance.html index 5d4944a77..4dec382d5 100644 --- a/projectify/user/templates/socialaccount/base_entrance.html +++ b/projectify/user/templates/socialaccount/base_entrance.html @@ -2,9 +2,6 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {% extends "storefront_base.html" %} {% load i18n %} -{% block storefront_header %} - {% include "common/navigation/header/landing.html" %} -{% endblock storefront_header %} {% block title %} {% block head_title %} {% endblock head_title %} diff --git a/projectify/user/templates/socialaccount/base_manage.html b/projectify/user/templates/socialaccount/base_manage.html index b340824c0..0bc2260b1 100644 --- a/projectify/user/templates/socialaccount/base_manage.html +++ b/projectify/user/templates/socialaccount/base_manage.html @@ -3,9 +3,6 @@ {# used by socialaccount/connections.html #} {% extends "storefront_base.html" %} {% load i18n %} -{% block storefront_header %} - {% include "common/navigation/header/landing.html" %} -{% endblock storefront_header %} {% block title %} {% block head_title %} {% endblock head_title %} diff --git a/projectify/user/templates/user_profile_base.html b/projectify/user/templates/user_profile_base.html index 89f20f4bc..fefdf7389 100644 --- a/projectify/user/templates/user_profile_base.html +++ b/projectify/user/templates/user_profile_base.html @@ -1,20 +1,17 @@ {# SPDX-FileCopyrightText: 2025-2026 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "base.html" %} {% load i18n %} -{% block storefront_header %} +{% block body %} {% include "common/navigation/header/dashboard.html" %} -{% endblock storefront_header %} -{% block storefront_content %}
-
-
-

{% trans "User account settings" %}

-
- {% block user_profile_content %} - {% endblock user_profile_content %} -
+
+

{% trans "User account settings" %}

+
+ {% block user_profile_content %} + {% endblock user_profile_content %}
-{% endblock storefront_content %} + {% include "common/footer.html" %} +{% endblock body %} From 8ced8344698f9fd0f8ca64c80c8bfa151a286dca Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 19:04:35 +0900 Subject: [PATCH 10/41] Storefront: Remove unused solutions_base.html --- .../storefront/templates/solutions_base.html | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 projectify/storefront/templates/solutions_base.html diff --git a/projectify/storefront/templates/solutions_base.html b/projectify/storefront/templates/solutions_base.html deleted file mode 100644 index 7db97139b..000000000 --- a/projectify/storefront/templates/solutions_base.html +++ /dev/null @@ -1,15 +0,0 @@ -{# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} -{# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "base.html" %} -{% block body %} -
- {% include "common/navigation/header/landing.html" %} -
-
- {% block solutions_content %} - {% endblock solutions_content %} -
-
-
- {% include "common/footer.html" %} -{% endblock body %} From 0834c6daa4331e1967f021111c0cf61fab7ec240 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 18:54:33 +0900 Subject: [PATCH 11/41] Storefront: Simplify page layout --- projectify/static/css/dist/styles.css | 16 ------------ .../markdown_en/security/disclose.md | 16 ++++++------ .../markdown_en/security/general.md | 2 +- .../storefront/static/hero-accessibility.png | Bin 7482 -> 0 bytes .../static/hero-accessibility.png.license | 3 --- .../storefront/static/hero-accessibility.webp | Bin 4086 -> 0 bytes .../templates/storefront/accessibility.html | 17 +++---------- .../templates/storefront/contact_us.html | 23 ++++++++---------- .../templates/storefront/credits.html | 15 +++--------- .../templates/storefront/download.html | 14 +++-------- .../templates/storefront/free_software.html | 12 +++------ .../storefront/security/disclose.html | 13 +++------- .../storefront/security/general.html | 13 +++------- .../templates/storefront/storefront_hero.html | 20 +++++++++++++++ .../storefront/templates/storefront_base.html | 10 +++----- 15 files changed, 65 insertions(+), 109 deletions(-) delete mode 100644 projectify/storefront/static/hero-accessibility.png delete mode 100644 projectify/storefront/static/hero-accessibility.png.license delete mode 100644 projectify/storefront/static/hero-accessibility.webp create mode 100644 projectify/storefront/templates/storefront/storefront_hero.html diff --git a/projectify/static/css/dist/styles.css b/projectify/static/css/dist/styles.css index b9c4fceef..707faccb1 100644 --- a/projectify/static/css/dist/styles.css +++ b/projectify/static/css/dist/styles.css @@ -1246,10 +1246,6 @@ video { z-index: 10; } -.order-1 { - order: 1; -} - .order-last { order: 9999; } @@ -1489,10 +1485,6 @@ video { max-width: 4rem; } -.max-w-20 { - max-width: 5rem; -} - .max-w-2xl { max-width: 42rem; } @@ -2351,10 +2343,6 @@ video { } @media (min-width: 640px) { - .sm\:order-2 { - order: 2; - } - .sm\:grid { display: grid; } @@ -2549,10 +2537,6 @@ video { max-width: 42rem; } - .lg\:max-w-xs { - max-width: 20rem; - } - .lg\:grid-cols-\[1fr_max-content\] { grid-template-columns: 1fr max-content; } diff --git a/projectify/storefront/markdown_en/security/disclose.md b/projectify/storefront/markdown_en/security/disclose.md index fbfaa7cf0..da6f9bced 100644 --- a/projectify/storefront/markdown_en/security/disclose.md +++ b/projectify/storefront/markdown_en/security/disclose.md @@ -4,23 +4,21 @@ SPDX-FileCopyrightText: 2024 JWP Consulting GK SPDX-License-Identifier: AGPL-3.0-or-later --> -# JWP Consulting GK Vulnerability Disclosure Policy - -## Introduction +# Introduction JWP Consulting GK welcomes feedback from security researchers and the general public to help improve our security. If you believe you have discovered a vulnerability, privacy issue, exposed data, or other security issues in any of our assets, we want to hear from you. This policy outlines steps for reporting vulnerabilities to us, what we expect, what you can expect from us. -## Systems in Scope +# Systems in Scope This policy applies to any digital assets related to Projectify that are owned, operated, and maintained by JWP Consulting GK. -## Out of Scope +# Out of Scope - Assets or other equipment not owned by parties participating in this policy. Vulnerabilities discovered or suspected in out-of-scope systems should be reported to the appropriate vendor or applicable authority. -## Our Commitments +# Our Commitments When working with us, according to this policy, you can expect us to: @@ -29,7 +27,7 @@ When working with us, according to this policy, you can expect us to: - Work to remediate discovered vulnerabilities in a timely manner, within our operational constraints; and - Extend Safe Harbor for your vulnerability research that is related to this policy. -## Our Expectations +# Our Expectations In participating in our vulnerability disclosure program in good faith, we ask that you: @@ -43,11 +41,11 @@ In participating in our vulnerability disclosure program in good faith, we ask t - You should only interact with test accounts you own or with explicit permission from the account holder; and - Do not engage in extortion. -## Official Channels +# Official Channels Please report security issues via [hello@projectifyapp.com](mailto:hello@projectifyapp.com), providing all relevant information. The more details you provide, the easier it will be for us to triage and fix the issue. -## Safe Harbor +# Safe Harbor When conducting vulnerability research, according to this policy, we consider this research conducted under this policy to be: diff --git a/projectify/storefront/markdown_en/security/general.md b/projectify/storefront/markdown_en/security/general.md index f56272818..618e75968 100644 --- a/projectify/storefront/markdown_en/security/general.md +++ b/projectify/storefront/markdown_en/security/general.md @@ -4,7 +4,7 @@ SPDX-FileCopyrightText: 2024 JWP Consulting GK SPDX-License-Identifier: AGPL-3.0-or-later --> -# Security +# Introduction This page explains measures taken by JWP Consulting GK (hereinafter referred to as "JWP") to ensure the security of the Projectify software (hereinafter diff --git a/projectify/storefront/static/hero-accessibility.png b/projectify/storefront/static/hero-accessibility.png deleted file mode 100644 index f761fd980bc2f36bd018497b9c23364c31eb8c24..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7482 zcmV-A9mV2_P)+)YW<`1$VV+~;3pMAnWz{#en;k5$GW@7>QSlhN4y zd-=#H8)gJBMG>@=GGn>r|g$4m%*yuW39!B2-~fLtw?G+mi~a&(+f% z{`U1~`C>Pm!vUt$Gp@h}SPP2_0xNVgRg}F)2&k%M{4yAD_4NepWB3qQA^I3nS^|oL z3L(`HZWYxLhu1R>uOousAh1Hx?e!5-!I*>>DzAVbuzYhNg?2A+*-6{@2%A~`LSXqI zE#HRTJ6SmI6T~Cm`*lELbA-E%%|l={3Ds0eCM_eXt_C5nn&5&;Jw!E?D%t0yp3w2j zLxNf%u<9~t`wZRZuX2~54Kn_Cgy5-E3ySF11W=D2 zUt@c8`O{D6-A^wF7D9!!h_ruNKjyE{bpmJ(+QbVWu+GZw{yjXo`v>3&+QfO#5>Csj zsy}g9eMta*Nt?J!S3Z5uZ6d0z6R5BRswxN?s;vnGR$f(oi#=5kxQvwfihgMK^wajX zgi=*q`RtEec7MZRg`MgUI=)8TokVK$oy2xlShBB5>m%F&gh*vHHn-c@B5G=zt(*6$ zqhBGcAYxs24R7tVZq2nlE#agERJ70k^a%mP?Gtx5`|`hS(xrd-ZyLS*JGDSNTEe^q ze2#^6NK|WD#PnOj?VGzNEnt@b(v-J|+r3>%T-q(6oC8$!4WR{u^zk*Wev|rL#57w% zS+DBoulYxN1kgI}@nc`5_}s+w0t;If07R1OT4}9eebMWkEUqK0pzUN`>$!UaPe?j} zB}Z%w?gRo%Np3MdjYaqEoA*d_-;Tv~fU_ox@iO({uN1lX11rB1_?Q4-F}KMy{yT_q z2UZ?d`vd^XDeA`_TJZ)J!U~|c$U`g6z}njRn!oUWAOI*X;?RmOu(o$(*xv650Lqil zy7F0cmMYnia;EJg$4F_Sex-* zNA5$plV8{y7dmBs9m|?l_-6?bRspjEzJy*AnE{PyxbWPX9GNn50zvorzq!u;O`jk< zmS1Ood-;e07It7EtU|<<6CKb8^SJah06E{*;YDBT9eb3>~&?agFPG> zS5lAYcz8%v+V1TkxI$z0`1|~X0u*Lo$w6j0X$~P+zoJ^jwVi!_cYjC_24!xC_zcAG z0?V}v`V|qL6&Qeww7E;)51V;ZI%e;OWW?Y5+l_tiR}|KGnaKr%STj{j$m-%o>F_-z$sd;Hq`ngS8V#?gHH0?r5em(keX4d$Vom#z*- z=`Z=(_*Vj-dZBDA_1_Z8a|6jfCGaiGhZf)_OhEVs%<<6c`l8o2$JM{o?9B_`UT@0?+t+aOvuI?t9|vPe8t4kv$-MNAUR7Z3@};O}=|<`vksa z?(VUtMRsieOmP}y4x>Z<|iSNa)4bB;c zM41P4jbGahX(q7pL(Sh3gyjPTbrOFiG5(oTXZf|=P;1ysV0AkCXaxf!hgt(U&{~N6 znXL>Hgn%@*)uH~t+TPj2srvva6Yt`+k~#7al!jWvdIL*ZK}?Yhl*%YpCgQM7Sp%(M zy@7>R5TH$JZw>1UtZuJ|Rxn_e-V;F8YHkhd3oNZMGA3k-E)hT-Onn3Y>Ip1t5QREv zjgt}~lMSU?JNs0F>I2K26{H6QVY8}7(K4tGL#OC|9cK-z4=h<|$63MfDchxJ#T>S+ zvB_Cb`2u{k1N>y6Js)QaF&LZAgByvw%olu(8C+x0sQ7`b)mB(oXpf85yA&IvQC-Ev zR$FCN7g(~R9SiMoQR)swN5vPit!DfAX_snL*Ai~+yg^_^XLQmQ#<6oee}c{%g4k3x z2fmsL3)NMuOPYCHK4UaVDRm9MaH z$0k*>XXdu|BRikBX~Z=9~R#B_izWLle+P&Q_Yzr3$7VZw_@-JRSAwl(3mftFB z8F^DO`W3a^1#RKtvxJ?_9wswzc^X<+iTs6jbn=1f>q1gRsTVG!eyC_HPFuN>y6;Tzn3alM3EEeXsgk#;uNRZWsiUloSOqP$wv(*<}k9L5bwHg1}p9Q-XwJyUq z0(8sULaqChU$-;n2bQvTT0g`{cC@GF?Aq5Pz^v}^_xu)r&n3V@duQiAvmLtgd8r1} zSxU(ZwcUZ0kDNI-BkO!%$wiEXTgmIRUlihOfC)J=vfC`TlA-};Lr)ofL13X2onP=`FM_6g>joT#Abz>4+cepBRv zLZt;&l08OSJ6|KLmYCWTITcv+OM;6@rM12D#?6=lSchz1(0ahHy9Wfz^d~VlLugiw0#?T zk1XAyyi&k9!7@%B29r-~Fr6+QI8mBhfV=La!yWe4jUg%hg2rQ+Cq5u(A6G?ncFCGs zZfzJZt@U+upZ|K82bSeq_ZW#>4R`Z`zn&d^pWAFyUv1##gz4ScD$2^WKv0pB2^N#U z>f(!A3shez$|nf)PO7LoZW=49sCu2;Da->);|^gXP<_SgV-@9|cW^TM9#vFxtb6QN z{?*1a2>r7b)mKR7RZ-xRd+c5wSlH4K8dP6C%&npz7{@QUo|SJ>N53Hm7pkv%po)si z*nFp&G1ud%tY!S61c*n<@n+<7cw;T=6JmPe;UUA{|b@eUKS**lNq zjtp7#f{YmwweUft`RpVKs6LUojyrlO(VH9jQ+)}Np=2g_-&J2?vVG$CI8ng9Uez!jF# zXbAxz0*V*i4c4##AP#5=0U#RO65>`c07PLE`>+5Y7Mo}Z0U#O(EC7fG0t*15fxrTQ zXdtivAQ}iP0Eh+x3jm@afi)rkfbfkpSp*gUL}7%M5CEcqzyg3cSj82VvKavYgiaZ0 z3H5sd0EmDlW3cEB01;3bw}c9T1pwi*^d9@L03a5!=uQ~{0ECXSiYqL%ga8l&xl5?W z836!NNoX1S>y6mM@Q70P!*D>DJd!45ew_U9G_Y`| zFjWI;3rkN3(lfWhQu#gyNQ^S=c@b-qekIvP`t+vYX<&`VsIbzLowQ2rfVMDAA5PcT zPXkNt6k_2$H3MoJO9uog$)esk8;&j(iijmF}8v;t@cyS=Xa@C|KY#M~S2 zxxz`$|6F{dqvf+5+`4(6ws+o9N563&+dJ~HN5153A-aVaSv5C<<_A^>yM^P#Z#2Hz z!L6N#r0iq<(dN{@?efRB?&o|!w-7HgKZE86*7rlKV@1jOxna`n^+?hF!k?F)yVmQI z7h{|)jL*VT8hYV>g4sf`8PW0ZkbLNL9xVMFqj$-NyhX;FZ(Ih8eqwQ8VS7VtJbu6I zou!|m=pI*Mo76OE3k}v}qjJFvx5a^l?G179HK;FNR9_{CHn|e(lMk5p9+S~UGu##j z7T%d+;&D))D$?rRCm%4_FD4gu3i_g-#5+?o2IPa=Yqdf<_>t{ONm zivtU7VH|w5gB{(cqO{&4AFw7HgVC}lJY5`EXbWTDuN_pXc#Ef0fr_j`XbWSo^i4%C z`vkOwfwSc24Mo~PYkO1}w}rm;WMSTW*jDHimIoHznZo94DnX~SPh}b1Cm%5HJ!C6& z3d;jaV8Jp>@_JWXcka!aS&Il;0v{k4Vm z1wnYm=Fuvjwpw7}oheMd=C*e_d$cZJM|F&OOc0j5Q^;2N)YSsZ{i3*A7#2^1`rJM$ zyZ)U%pGS4@<{K8Z`T_OT0}Hc-0%Lvcy^_kPilEs%`L&vdH{XzqXuSG4*wq6|-kGrY z9u9AV`s_WbjH>Y0Dgg7|Lvl24vgjhK2bTM6EWU@qTRYh0km*xpJRQ|RTNsWN7uzR_ z1r`?H17YJiinEN?%V%lT0c~Lbiofv|3oN0=g%S~JF!nV2%2M^Cj+H9EpKBdhlMO)e zH{RlbB~_NCjKG(}B0Ya)zHnoZyHq((M|Cjo-M6gFgxunRmBX1VZ;YQ-#&qgy-F)rI zLbu?NDfP847g#8#eOWqwqc-at#aTx7Wch&cNxo!dpFdD8u<|VqsIr>pC#<$_?$*-| zezfVWEyPwG4@~LGwroo{IpTnKrzZIt)JIf*%Vs-%4n$+CPSdh=_7Ij2EL2%d^E0R~ zKZ?^p%jRiM7RDzvOL0ZJ#va1*fhAC7HEBRTGQUS{LtV1GTT z%Z6$KOSlBZH@tEMrZHBkiZRG#8E=FAFz>y3l~q@wy1)`Rn^iqugZk<`inENbwh;5) ztEbIfs!+9o72EM8W|)1bckkK!z|>2ZR?fS@Xed8=KWY6DA*?LJ{MX*I0!)uQZ? zZ?;qY?5&l4U$li485Nx-x{B%pODY0vbEt-oCl$Lf$kxvW&Kb7n<*1IrjI2s1&VU-a zgX#lI{O#qwySoMJ`QV{atz!&wIew1njK{cc*O$=`oB=hcC$QYFJpMhwx;(Vj*3W|X zJidv9tux&$$)e4%|>eSsy$W*ojQNV zVis$@E$kAMmDRD(KCRxslH0`Ed__=>(LHKbcrBP}_;`?R{A-?&YR(H@q~5?1XbsC# zbe)>w?{fhEuymLqF8b-!4(maPmSzfdxsy2CDK`Fjz|Xka-# zVaB&BAEEq3ugB|TuLz2evw}C8KAhiNOE@9_gBbQ#oYhY&-9+hT@przBYL2 zl6+0a{I!K*q!siqNu=49aN_5;f2Av*UgjVDnqXC*aO<{9zqtCEzkV65Bd@Fme9`qO zKEI;NpY3vc_?NZYL)psF(a*6&QP=^ug8SrwpTH7sJxl$DFBY_htK+Yg+M~;#60d2M z8-pS*Qmy~QU-w^8tR!G0ysv!vp1%&okAX;FU-|5h{A+we@YaJPXJmQOIwIP45notkJIxAQOJD{s@+L*LSo3x?3e5+n|%kNV9FVKuXhI~c8pbtiVoPQlUvPReG z5t-}hg>}=+T{>RjjiE;V14~@n*~cCjfPlIER({s4rukdLn)dTg171^*CmZA7DL{CR zc#-`-C;-iGYglAFet;>Z0Kt((_F#_Hg%en^8PJUH;*c~zFeI$1->AEWFxIf9<-3lb z6Yk;w_~iYL;5^|}SQ9c{RhdT5Cjr12@)`^*tVDQ$CES@gjH3bYLecM=oWM{-767umnO2pnegAl?Xqugxgq( z=?(z3h$O5;9Dya?{q$$-h6kvceL}~t!h0U&0`UZvc=yv0LJOcOF0A$_B5?(lkkG=K z7Qng=IIMn0k%=#`1Qy}}%9Ay#k|Tq*N8iY|D@xW z`>C#1&C`yS@GP{55VJ(LEUcnE71p%T+Ywh=2e^v}5jxzUVg0xCx8eU%%b~)Ww%yw! zs~@7;fGCNp9oqvw1msZMAJ)GzeAe{H9E}OzgL(rvIHuuBG4v6rR;!|eT4RJ zbqK67a5oXwlM$ImbUZvHCl-y!UrkDXLdPq?BwLQpjaf#pNDx5?{VyJXdUQrJBo7gtR^ zL)+&E0?W5a)n(N!taSwo)l?u5SRs+=r&jHuWekFZ)Def&6NFSa5Llsd4}ev>2(Lz+ zhSUcdqU8$@0xJfS$_s5{9ZuUlgjDblSh10b5mq*)O67%Z5+!gT*`ZrFS2UpSheLvB zAh2R4cN;f|159)4Xs;u{7U4U4q+X+niXQ?iHPSwk3$a^ViS=67LRy=VQd~iq7yM5{ z1XNlOSgCV|vN^Pj?(=v1I=`U1$?W~g!)d_b^qRvtqp=I8j38MEthSQ1FvCG+wNzoh zb2S4G>K2gusIHCRLer+bK3V)Hul8k1x4k6O*44N}c|A zu@Q&QX=|or>ZTe|ewXgO3nFDQ_s^Pt?w|a3gc6+b|GU($NuOK65C8xG07*qoM6N<$ Ef`>j^-~a#s diff --git a/projectify/storefront/static/hero-accessibility.png.license b/projectify/storefront/static/hero-accessibility.png.license deleted file mode 100644 index d723339a2..000000000 --- a/projectify/storefront/static/hero-accessibility.png.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: 2024 JWP Consulting GK - -SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/projectify/storefront/static/hero-accessibility.webp b/projectify/storefront/static/hero-accessibility.webp deleted file mode 100644 index 970feb237ebb0d1c03c0dc3f71fb0535c6e2f60d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4086 zcmVPm zaA}rQMQyEVxh8>%sL8z3)d;kU8ZP^KA!rvhUv|0#R79QR9VaiOs;)j`m^Uh-?#hmH zMpd1zak@fcb)4f=g-z6XjyOwU6P-ZbaE8JrI*GjD?1W8pB00`YWSvZo^Fmc8l$SZd zLnn2|3E4!aRkqonADvoWW+L2ldUu=#Vx3@)a}aJi$vaK}u}(9Scj2Z}z4?YeI@yel z{VO`%lDrBxo$_0730Wtd(VE+xE+x3F(X6<@OccV7Fs5g@~b(ERK5#0>h)X7>_ zgkP7nR3~&ZXHyp?2lx;pyP%sf|Eh-^;nc_;=s%{AIw(27+1UdnLr@&x1sWndAd4Bu zYJUyTG~De|Mk4~N{RcEZ?9*qk;jZ={(BWa9?j~$%KZhQ|(>~n{Sk-<;=n};~U57() zAVR0f0l~ontNj(at@bI0v2N-h_CpTnZK!|M!3T6)9ndroiU-sac_26rtAh+(iv#)$ zqipKo-dFKJZ-bD!NPUqDN<$#~5c&!}a2VlG7oX5MyP&rLqWV~Sd-$N#9kLU9Z-)~c zdOP&W>8-Oxy)2WW)EB$4&&so_O=sodhUSh?{CM_O{SfTuP(P(N@k94+7WEYSTGUhO zgqjh<9HJOA_LaJ{zO`1&Sn2Q=eRBiCK{mhS1};kV7Ue5uWCS=on%1$OAXv-=V5?` z-^c=)9kzgeOBN{24XK6cw}%OJZVnS3zp)8QGlOa(|AK1cb7t5E#TPO{Z}YOLk?Tvg zLbqAj)XMboFhg%s5^ied{_?a#d6^ITQ9JoXO|9!(P*qa_V6jD2?h)r8q`RoG2(Vi7 z`Yhxfm8TjU0kTD^D))$9VY-XjoB$4^UY~{B2~=3^-T|s%sw(%0eqgGirau7Kw%2DN z2LcsV^BF*0lzT+WFjY|(0eyIV7BVSNVRaM94^@?WL|ZUbQBRlEmDgt>BLWpxZ`bI~ zYt1W;g=?vz4#(`$tK7%Ai+Y`lTgv0Gx?PTM%3i-IkFQF7ep8tLYel{(qTdwH|6e!x zrU-vi_`g=3^_x;3UzOvVvX^g4$={S5zbSG3rmV|1h47n#@J#{W?+F7uz9$vn_@3+l z>3fm^;`d|)!0*Wd0B-H$9Bu&suvhCi`cq-}>QDWv z_!GxV{*=GS9~>|ELw^PFrh!LWxw5yCza+GQ!xefjh$qx2>YHmL z@f51Fab*1@h#%A_zLy%29|9jAZZI@{zE>N8xUqjm zZj?r-A5x?227LzN#nR~bBsU^27)~rsJfSgvB8{Lrv7bRsq(-D4Q={wzo&nVdY;=59 z8U-JoaB6a)LfaspNoWJrg?LU74`QRJC&os}0}1Cq@c=UOW%jx*ggsdNQ?vY8^wvWZl~6IIA=w>tc%_ zwh1)mv!qcF+ZdW=+xAAQo=$C`*cRIaF^w7>=iM8TX^d?kTgK2f==l=bfLNBHaS+4y zMzMjWM#V73R*(&&Mzuw4M22N(9>gq$wx~@*8(^~-+d(#~G)ir9qij}$4g*_-jkpar zTC8H|G8*-Swxo@z4OF9IA5d+IjgD5OQMQSpQ?dygaVu#QY+8DO!=egpS8Yyhpjxy; zwlf{}|U%Jf?>f?$WV0XSA z1uV~3BZKYvT4b<3Ux^I%r|S^GLy~JC1U@2P0bamM*|vJ+g#^$^QsrvMj| z4z@`O1-PV3s>Vo73tZVirCL!71-QbL?mQOX69u@^NcsL8f%mi`T=Zqld*wd(zNd)r z4MbXFE_Qw~m#6JJJD zP}<{oL(j@AEINuGOU{lZJ1;-$pUTIGo9pS`7Aswym!HYNvSD9AB_%h@is;WxkiSlk zpeGZ0ynQ-kREj9+2G@5!Z978c+cKd0`7lBjRPt z6wH4iIC(GaKrE)!%nrPZ99LI7oSdPRvlNl`vg~JEtj}nxI;#QFPAun(u3<-(z8f*-1B`ylGEL zKMSZUDvyUh*B4|R>O=ygci3L^s(2i1r|J}SpjT#=Rb&DOvF0T+2jQC7lkccToHtJO z8w&NdHxNf3BU?`513!P4+wzn22-S2Dai2F-VT^hF#I%IDA<#?-J(5ALDUhr8Gz?V9 zZmZ%m_$1(ZL~V%+FHSvDy~hFC98K}m;Yv|1U8lNuEM2jxWDaFJ5wMM;eW;k3*d$qG z@kgti_Z;C8GB7(_M~s1=Y=p7((Bwq)YugM$*ww??3+ILxVWUQ?(CRbCR3M)5o{C0U zQ|ln_A#jqdSJJFS zz7$=4=q_W$K>(G+I4_%{&K0ygI)DlSH=@JberFevL#KFxnim2v47Cm0&M_fmdbEpa zN3vJN5naniUt%9U9h(h%JMxk(uUkd4Zp{qSE z(ntZ@){c`E>ZQ`kc3qxwRGgtE%R~vu?4Ny`<`Ka$cY}yy=;j>zA#=xtnX=53uIh=d zIdusaYc`=9iW{|e0wA%lmpKxjSijKFAl`g38rv5%lh+mtso>!HU%a#OQ zxlpy0F)?BMiiNwTY2HRgUeCpmev~dTL@`Jb6mJ*zKGXI}&(wVyrczP9O#o77eV~ z5u^n)B~YJ00;p;X7Z;$Tw002+?`-N&35a@##tm0nF>yTT_;bxSfc;3j) o{Rr{4`8#OsBV}UZzm|RWjSEog<0XYbCpnCfSd3!`^?(2X08 -
-
-

{% trans "Accessibility statement" %}

- {% picture src="hero-accessibility.png" alt=_("Accessibility logo") klass="justify-self-center order-1 sm:order-2 max-w-20 lg:max-w-xs" fetchpriority="high" %} -
-
-
{{ content }}
- -{% endblock storefront_content %} +{% block hero_title %} + {% trans "Accessibility statement" %} +{% endblock hero_title %} diff --git a/projectify/storefront/templates/storefront/contact_us.html b/projectify/storefront/templates/storefront/contact_us.html index 3f304ec69..a60d7626d 100644 --- a/projectify/storefront/templates/storefront/contact_us.html +++ b/projectify/storefront/templates/storefront/contact_us.html @@ -1,20 +1,17 @@ {# SPDX-FileCopyrightText: 2024-2026 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "storefront/storefront_hero.html" %} {% load i18n %} {% load projectify %} {% block title %} {% translate "Contact - Projectify" %} {% endblock title %} -{% block storefront_content %} -
- {% include "storefront/common/storefront_header.html" with title=_("Contact") %} -
-
{{ content }}
- -
-
-{% endblock storefront_content %} +{% block hero_title %} + {% trans "Contact" %} +{% endblock hero_title %} +{% block hero_content %} +
+
{{ content }}
+ +
+{% endblock hero_content %} diff --git a/projectify/storefront/templates/storefront/credits.html b/projectify/storefront/templates/storefront/credits.html index 18dc8a61f..8819b22d6 100644 --- a/projectify/storefront/templates/storefront/credits.html +++ b/projectify/storefront/templates/storefront/credits.html @@ -1,17 +1,10 @@ {# SPDX-FileCopyrightText: 2024-2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "storefront/storefront_hero.html" %} {% load i18n %} {% block title %} {% translate "Credits and attribution - Projectify" %} {% endblock title %} -{% block storefront_content %} -
- {% trans "Credits and attribution" as title %} - {% include "storefront/common/storefront_header.html" with title=title %} -
-
{{ content }}
-
-
-{% endblock storefront_content %} +{% block hero_title %} + {% trans "Credits and attribution" %} +{% endblock hero_title %} diff --git a/projectify/storefront/templates/storefront/download.html b/projectify/storefront/templates/storefront/download.html index 23e7a9de1..8ae8e4323 100644 --- a/projectify/storefront/templates/storefront/download.html +++ b/projectify/storefront/templates/storefront/download.html @@ -1,17 +1,11 @@ {# SPDX-FileCopyrightText: 2026 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "storefront/storefront_hero.html" %} {% load projectify %} {% load i18n %} {% block title %} {% translate "Download - Projectify" %} {% endblock title %} -{% block storefront_content %} -
- {% include "storefront/common/storefront_header.html" with title=_("Download and install Projectify") %} -
-
{{ content }}
-
-
-{% endblock storefront_content %} +{% block hero_title %} + {% trans "Download and install Projectify" %} +{% endblock hero_title %} diff --git a/projectify/storefront/templates/storefront/free_software.html b/projectify/storefront/templates/storefront/free_software.html index f1bcb166a..4aa22f094 100644 --- a/projectify/storefront/templates/storefront/free_software.html +++ b/projectify/storefront/templates/storefront/free_software.html @@ -1,14 +1,10 @@ {# SPDX-FileCopyrightText: 2024-2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "storefront/storefront_hero.html" %} {% load i18n %} {% block title %} {% translate "Free software - Projectify" %} {% endblock title %} -{% block storefront_content %} -
- {% include "storefront/common/storefront_header.html" with title=_("Free Software and License Information") %} -
{{ content }}
-
-{% endblock storefront_content %} +{% block hero_title %} + {% trans "Free Software and License Information" %} +{% endblock hero_title %} diff --git a/projectify/storefront/templates/storefront/security/disclose.html b/projectify/storefront/templates/storefront/security/disclose.html index f35105749..60757fa56 100644 --- a/projectify/storefront/templates/storefront/security/disclose.html +++ b/projectify/storefront/templates/storefront/security/disclose.html @@ -1,15 +1,10 @@ {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "storefront/storefront_hero.html" %} {% load i18n %} {% block title %} {% translate "Vulnerability Disclosure Policy - Projectify" %} {% endblock title %} -{% block storefront_content %} -
-
-
{{ content }}
-
-
-{% endblock storefront_content %} +{% block hero_title %} + {% trans "JWP Consulting GK Vulnerability Disclosure Policy" %} +{% endblock hero_title %} diff --git a/projectify/storefront/templates/storefront/security/general.html b/projectify/storefront/templates/storefront/security/general.html index dc517c64c..3dabd7d70 100644 --- a/projectify/storefront/templates/storefront/security/general.html +++ b/projectify/storefront/templates/storefront/security/general.html @@ -1,15 +1,10 @@ {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "storefront/storefront_hero.html" %} {% load i18n %} {% block title %} {% translate "Security Information - Projectify" %} {% endblock title %} -{% block storefront_content %} -
-
-
{{ content }}
-
-
-{% endblock storefront_content %} +{% block hero_title %} + {% trans "Security information" %} +{% endblock hero_title %} diff --git a/projectify/storefront/templates/storefront/storefront_hero.html b/projectify/storefront/templates/storefront/storefront_hero.html new file mode 100644 index 000000000..b529f45e5 --- /dev/null +++ b/projectify/storefront/templates/storefront/storefront_hero.html @@ -0,0 +1,20 @@ +{# SPDX-FileCopyrightText: 2024-2026 JWP Consulting GK #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} +{% extends "storefront_base.html" %} +{% load i18n %} +{% block storefront_content %} +
+
+

+ {% block hero_title %} + {% endblock hero_title %} +

+
+
+ {% block hero_content %} +
{{ content }}
+ {% endblock hero_content %} +
+
+{% endblock storefront_content %} diff --git a/projectify/storefront/templates/storefront_base.html b/projectify/storefront/templates/storefront_base.html index efafa7a26..d7305605f 100644 --- a/projectify/storefront/templates/storefront_base.html +++ b/projectify/storefront/templates/storefront_base.html @@ -3,13 +3,9 @@ {% extends "base.html" %} {% block body %}
- {% block storefront_header %} - {% include "common/navigation/header/landing.html" %} - {% endblock storefront_header %} -
- {% block storefront_content %} - {% endblock storefront_content %} -
+ {% include "common/navigation/header/landing.html" %} + {% block storefront_content %} + {% endblock storefront_content %}
{% include "common/footer.html" %} {% endblock body %} From 265b027d888311630de9f67fb1466cf3ccac8095 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 19:44:32 +0900 Subject: [PATCH 12/41] User: Simplify base layouts --- projectify/static/css/dist/styles.css | 4 ---- projectify/user/templates/user_base.html | 14 +++++++---- .../user/templates/user_profile_base.html | 23 +++++++++---------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/projectify/static/css/dist/styles.css b/projectify/static/css/dist/styles.css index 707faccb1..6f34e479d 100644 --- a/projectify/static/css/dist/styles.css +++ b/projectify/static/css/dist/styles.css @@ -1545,10 +1545,6 @@ video { flex-grow: 0; } -.basis-1\/4 { - flex-basis: 25%; -} - .-rotate-12 { --tw-rotate: -12deg; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); diff --git a/projectify/user/templates/user_base.html b/projectify/user/templates/user_base.html index 4fa310914..746cc9e21 100644 --- a/projectify/user/templates/user_base.html +++ b/projectify/user/templates/user_base.html @@ -1,11 +1,17 @@ {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "base.html" %} {% load i18n %} -{% block storefront_content %} -
+{% block body %} + {% block user_header %} + {% include "common/navigation/header/landing.html" %} + {% endblock user_header %} +
+ {% block user_title %} + {% endblock user_title %}
{% block user_content %}{% endblock %}
-{% endblock storefront_content %} + {% include "common/footer.html" %} +{% endblock body %} diff --git a/projectify/user/templates/user_profile_base.html b/projectify/user/templates/user_profile_base.html index fefdf7389..4b9076199 100644 --- a/projectify/user/templates/user_profile_base.html +++ b/projectify/user/templates/user_profile_base.html @@ -1,17 +1,16 @@ {# SPDX-FileCopyrightText: 2025-2026 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "base.html" %} +{% extends "user_base.html" %} {% load i18n %} -{% block body %} +{% block user_header %} {% include "common/navigation/header/dashboard.html" %} -
-
-

{% trans "User account settings" %}

-
- {% block user_profile_content %} - {% endblock user_profile_content %} -
-
+{% endblock user_header %} +{% block user_title %} +

{% trans "User account settings" %}

+{% endblock user_title %} +{% block user_content %} +
+ {% block user_profile_content %} + {% endblock user_profile_content %}
- {% include "common/footer.html" %} -{% endblock body %} +{% endblock user_content %} From fc4d0163146cda607e73635cbcbb198df0cc09ae Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 19:54:13 +0900 Subject: [PATCH 13/41] Refactor use of storefront_base --- .../storefront/templates/storefront/privacy.html | 9 ++++++--- .../storefront/templates/storefront/tos.html | 9 ++++++--- projectify/templates/test/debug_error_pages.html | 13 +++++++------ .../templates/socialaccount/base_entrance.html | 14 ++++++-------- .../user/templates/socialaccount/base_manage.html | 14 ++++++-------- .../user/templates/user/test_email_confirm.html | 8 ++++---- .../templates/user/test_email_update_confirm.html | 8 ++++---- projectify/user/templates/user/test_index.html | 8 ++++---- .../user/test_password_reset_confirm.html | 8 ++++---- 9 files changed, 47 insertions(+), 44 deletions(-) diff --git a/projectify/storefront/templates/storefront/privacy.html b/projectify/storefront/templates/storefront/privacy.html index 7e494858d..c67daa4a9 100644 --- a/projectify/storefront/templates/storefront/privacy.html +++ b/projectify/storefront/templates/storefront/privacy.html @@ -1,12 +1,15 @@ {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "storefront/storefront_hero.html" %} {% load projectify %} {% load i18n %} {% block title %} {% translate "Privacy policy - Projectify" %} {% endblock title %} -{% block storefront_content %} +{% block hero_title %} + {% trans "Privacy policy" %} +{% endblock hero_title %} +{% block hero_content %}
@@ -39,4 +42,4 @@
-{% endblock storefront_content %} +{% endblock hero_content %} diff --git a/projectify/storefront/templates/storefront/tos.html b/projectify/storefront/templates/storefront/tos.html index 0b9733bb2..174a213d9 100644 --- a/projectify/storefront/templates/storefront/tos.html +++ b/projectify/storefront/templates/storefront/tos.html @@ -1,12 +1,15 @@ {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "storefront/storefront_hero.html" %} {% load projectify %} {% load i18n %} {% block title %} {% translate "Terms of service - Projectify" %} {% endblock title %} -{% block storefront_content %} +{% block hero_title %} + {% trans "Terms of service" %} +{% endblock hero_title %} +{% block hero_content %}
@@ -29,4 +32,4 @@
-{% endblock storefront_content %} +{% endblock hero_content %} diff --git a/projectify/templates/test/debug_error_pages.html b/projectify/templates/test/debug_error_pages.html index 014915cce..34b9a09db 100644 --- a/projectify/templates/test/debug_error_pages.html +++ b/projectify/templates/test/debug_error_pages.html @@ -1,18 +1,19 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {# SPDX-FileCopyrightText: 2026 JWP Consulting GK #} -{% extends "storefront_base.html" %} +{% extends "base.html" %} {% load i18n %} {% load projectify %} {% block title %} {% translate "Debug Error Pages" %} {% endblock title %} -{% block storefront_content %} -
-

{% translate "Debug Error Pages" %}

-
    +{% block body %} +
    +

    {% translate "Debug Error Pages" %}

    +
      {% for url, label in error_pages %}
    • {% anchor href=url label=label %}
    • {% endfor %}
    + {% anchor label=_("Back to landing") href="storefront:landing" %}
    -{% endblock storefront_content %} +{% endblock body %} diff --git a/projectify/user/templates/socialaccount/base_entrance.html b/projectify/user/templates/socialaccount/base_entrance.html index 4dec382d5..c8e05993a 100644 --- a/projectify/user/templates/socialaccount/base_entrance.html +++ b/projectify/user/templates/socialaccount/base_entrance.html @@ -1,16 +1,14 @@ {# SPDX-FileCopyrightText: 2026 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "user_base.html" %} {% load i18n %} {% block title %} {% block head_title %} {% endblock head_title %} {% endblock title %} -{% block storefront_content %} -
    -
    - {% block content %} - {% endblock content %} -
    +{% block user_content %} +
    + {% block content %} + {% endblock content %}
    -{% endblock storefront_content %} +{% endblock user_content %} diff --git a/projectify/user/templates/socialaccount/base_manage.html b/projectify/user/templates/socialaccount/base_manage.html index 0bc2260b1..c59fad738 100644 --- a/projectify/user/templates/socialaccount/base_manage.html +++ b/projectify/user/templates/socialaccount/base_manage.html @@ -1,17 +1,15 @@ {# SPDX-FileCopyrightText: 2026 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} {# used by socialaccount/connections.html #} -{% extends "storefront_base.html" %} +{% extends "user_base.html" %} {% load i18n %} {% block title %} {% block head_title %} {% endblock head_title %} {% endblock title %} -{% block storefront_content %} -
    -
    - {% block content %} - {% endblock content %} -
    +{% block user_content %} +
    + {% block content %} + {% endblock content %}
    -{% endblock storefront_content %} +{% endblock user_content %} diff --git a/projectify/user/templates/user/test_email_confirm.html b/projectify/user/templates/user/test_email_confirm.html index 5460f4214..9aa97c44f 100644 --- a/projectify/user/templates/user/test_email_confirm.html +++ b/projectify/user/templates/user/test_email_confirm.html @@ -1,8 +1,8 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} -{% extends "storefront_base.html" %} -{% block storefront_content %} -
    +{% extends "user_base.html" %} +{% block user_content %} +

    Email Confirmation Test Page

    Test Data

    -{% endblock %} +{% endblock user_content %} diff --git a/projectify/user/templates/user/test_email_update_confirm.html b/projectify/user/templates/user/test_email_update_confirm.html index f540c7347..7d784d5a9 100644 --- a/projectify/user/templates/user/test_email_update_confirm.html +++ b/projectify/user/templates/user/test_email_update_confirm.html @@ -1,8 +1,8 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} -{% extends "storefront_base.html" %} -{% block storefront_content %} -
    +{% extends "user_base.html" %} +{% block user_content %} +

    Email Address Update Confirmation Test Page

    Test Data

      @@ -27,4 +27,4 @@

      Current User Status

      User: {{ user.email }}

      Unconfirmed Email: {{ user.unconfirmed_email }}

    -{% endblock %} +{% endblock user_content %} diff --git a/projectify/user/templates/user/test_index.html b/projectify/user/templates/user/test_index.html index 0ba1f0c2b..9b6b72bee 100644 --- a/projectify/user/templates/user/test_index.html +++ b/projectify/user/templates/user/test_index.html @@ -1,9 +1,9 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {# SPDX-FileCopyrightText: 2025-2026 JWP Consulting GK #} -{% extends "storefront_base.html" %} +{% extends "user_base.html" %} {% load projectify %} -{% block storefront_content %} -
    +{% block user_content %} +

    User app test views

    You can test Projectify user app features using the buttons below.

    Authentication status

    @@ -71,4 +71,4 @@

    allauth socialaccount test

    {% endif %}
    -{% endblock %} +{% endblock user_content %} diff --git a/projectify/user/templates/user/test_password_reset_confirm.html b/projectify/user/templates/user/test_password_reset_confirm.html index f94a86ca6..18b9dbf82 100644 --- a/projectify/user/templates/user/test_password_reset_confirm.html +++ b/projectify/user/templates/user/test_password_reset_confirm.html @@ -1,8 +1,8 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} -{% extends "storefront_base.html" %} -{% block storefront_content %} -
    +{% extends "user_base.html" %} +{% block user_content %} +

    Password Reset Confirmation Test Page

    Test Data

    -{% endblock %} +{% endblock user_content %} From b1d0e22cdd5612df9879503c7859851bcad156c8 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 19:54:23 +0900 Subject: [PATCH 14/41] Workspace: Make active_invites a list --- projectify/workspace/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projectify/workspace/models.py b/projectify/workspace/models.py index 454f9d8b6..102361dd4 100644 --- a/projectify/workspace/models.py +++ b/projectify/workspace/models.py @@ -72,7 +72,7 @@ def __init__(self, *args: Any, **kwargs: Any): teammember_set: RelatedManager["TeamMember"] attachment_set: RelatedManager["Attachment"] teammemberinvite_set: RelatedManager["TeamMemberInvite"] - active_invites: Optional[RelatedManager["TeamMemberInvite"]] + active_invites: Optional[list["TeamMemberInvite"]] def __str__(self) -> str: """Return title.""" From 245293dc7040870f8791839ddf2e7154135dc891 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 19:57:04 +0900 Subject: [PATCH 15/41] Workspace: Fix TODO --- projectify/workspace/services/task.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/projectify/workspace/services/task.py b/projectify/workspace/services/task.py index ccbc326a3..2a8f26f52 100644 --- a/projectify/workspace/services/task.py +++ b/projectify/workspace/services/task.py @@ -62,7 +62,6 @@ def task_create( return task -# Update @transaction.atomic def task_update( *, @@ -98,8 +97,7 @@ def task_mark_done(*, who: User, task: Task, done: bool) -> Task: return task -# Delete -# TODO atomic +@transaction.atomic def task_delete(*, task: Task, who: User) -> None: """Delete a task.""" validate_perm("workspace.delete_task", who, task.workspace) From c15e77bc687e7a13d3f3419fcccdada905943269 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 20:02:21 +0900 Subject: [PATCH 16/41] Refactor UUID base models --- projectify/lib/models.py | 20 +++++++++++++++++++- projectify/workspace/models.py | 14 +++++--------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/projectify/lib/models.py b/projectify/lib/models.py index 7e71528a0..4e6522a12 100644 --- a/projectify/lib/models.py +++ b/projectify/lib/models.py @@ -6,10 +6,17 @@ import datetime from collections.abc import Iterable, Sequence from typing import Any, Callable, Optional +from uuid import uuid4 from django import forms from django.conf import settings -from django.db.models import CharField, DateTimeField, Model, TextField +from django.db.models import ( + CharField, + DateTimeField, + Model, + TextField, + UUIDField, +) from django.utils import safestring from django.utils.html import strip_tags from django.utils.translation import gettext_lazy as _ @@ -154,6 +161,17 @@ class Meta: get_latest_by = "modified" +class BaseModelUUID(BaseModel): + """BaseModel with an additional hidden uuid field.""" + + uuid = UUIDField(unique=True, default=uuid4, editable=False) + + class Meta: + """Make this model abstract.""" + + abstract = True + + # SPDX-SnippetBegin # SPDX-License-Identifier: MIT # SPDX-SnippetCopyrightText: 2022 LOGIC SMPC diff --git a/projectify/workspace/models.py b/projectify/workspace/models.py index 102361dd4..3a8978302 100644 --- a/projectify/workspace/models.py +++ b/projectify/workspace/models.py @@ -4,7 +4,6 @@ """Workspace models.""" import logging -import uuid from pathlib import Path from typing import TYPE_CHECKING, Any, Optional @@ -17,6 +16,7 @@ from projectify.lib.models import ( BaseModel, + BaseModelUUID, RichTextField, TitleDescriptionModel, ) @@ -36,7 +36,7 @@ logger = logging.getLogger(__name__) -class Workspace(TitleDescriptionModel, BaseModel): +class Workspace(TitleDescriptionModel, BaseModelUUID): """Workspace.""" users = models.ManyToManyField( @@ -44,7 +44,6 @@ class Workspace(TitleDescriptionModel, BaseModel): through="TeamMember", through_fields=("workspace", "user"), ) # type: models.ManyToManyField[User, "TeamMember"] - uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) picture = models.ImageField( upload_to="workspace_picture/", blank=True, null=True ) @@ -105,7 +104,7 @@ class Meta: ) -class Project(TitleDescriptionModel, BaseModel): +class Project(TitleDescriptionModel, BaseModelUUID): """Project.""" workspace = models.ForeignKey["Workspace"]( @@ -117,7 +116,6 @@ class Project(TitleDescriptionModel, BaseModel): null=True, policy=settings.HTML_USER_POLICY, ) - uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) archived = models.DateTimeField( null=True, blank=True, @@ -147,7 +145,7 @@ class Meta: ordering = ("-created",) -class Task(TitleDescriptionModel, BaseModel): +class Task(TitleDescriptionModel, BaseModelUUID): """Task, belongs to project.""" # Override description and make it a rich text field @@ -161,7 +159,6 @@ class Task(TitleDescriptionModel, BaseModel): "workspace.Workspace", on_delete=models.CASCADE ) project = models.ForeignKey[Project](Project, on_delete=models.CASCADE) - uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) assignee = models.ForeignKey["TeamMember"]( "TeamMember", null=True, @@ -233,7 +230,7 @@ class Meta: ordering = ("created",) -class TeamMember(BaseModel): +class TeamMember(BaseModelUUID): """Workspace to user mapping.""" workspace = models.ForeignKey["Workspace"]( @@ -251,7 +248,6 @@ class TeamMember(BaseModel): default=TeamMemberRoles.OBSERVER, ) job_title = models.CharField(max_length=255, null=True, blank=True) - uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) last_visited_project = models.ForeignKey( Project, on_delete=models.SET_NULL, From 05222a381602487a1322bb04152986af96e43d2c Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 20:18:19 +0900 Subject: [PATCH 17/41] Workspace: Simplify dashboard_base --- projectify/static/css/dist/styles.css | 4 - projectify/templates/dashboard_base.html | 92 ++++++++++++------- .../common/sidemenu/project_details.html | 30 ------ .../templates/workspace/form_base.html | 3 - 4 files changed, 57 insertions(+), 72 deletions(-) delete mode 100644 projectify/workspace/templates/workspace/common/sidemenu/project_details.html diff --git a/projectify/static/css/dist/styles.css b/projectify/static/css/dist/styles.css index 6f34e479d..53cff9cb9 100644 --- a/projectify/static/css/dist/styles.css +++ b/projectify/static/css/dist/styles.css @@ -1980,10 +1980,6 @@ video { padding-bottom: 5rem; } -.pb-4 { - padding-bottom: 1rem; -} - .pb-8 { padding-bottom: 2rem; } diff --git a/projectify/templates/dashboard_base.html b/projectify/templates/dashboard_base.html index 1d28c0d32..942b6baa0 100644 --- a/projectify/templates/dashboard_base.html +++ b/projectify/templates/dashboard_base.html @@ -3,42 +3,64 @@ {% extends "base.html" %} {% load i18n %} {% load projectify %} +{% load rules %} {% block body %} -
    - {% include "common/navigation/header/dashboard.html" %} -
    - - {% block dashboard_content %} - {% endblock dashboard_content %} -
    + {% include "common/navigation/header/dashboard.html" %} +
    + + {% block dashboard_content %} + {% endblock dashboard_content %}
    {% include "common/footer.html" %} {% endblock body %} diff --git a/projectify/workspace/templates/workspace/common/sidemenu/project_details.html b/projectify/workspace/templates/workspace/common/sidemenu/project_details.html deleted file mode 100644 index 175350296..000000000 --- a/projectify/workspace/templates/workspace/common/sidemenu/project_details.html +++ /dev/null @@ -1,30 +0,0 @@ -{# SPDX-License-Identifier: AGPL-3.0-or-later #} -{# SPDX-FileCopyrightText: 2024-2025 JWP Consulting GK #} -{% load i18n %} -{% load projectify %} -{% load rules %} -
    -
    -
    {% trans "Projects" %}
    -
    - {% for project_item in projects %} - -
    - {% if project_item == project %} - {% icon "folder" "white" size=4 %} - {% else %} - {% icon "folder" size=4 %} - {% endif %} -
    -
    {{ project_item.title }}
    -
    - {% endfor %} - {% if workspace %} - {% has_perm "workspace.create_project" user workspace as can_create_project %} - {% if can_create_project %} - {% go_to_action href="dashboard:workspaces:create-project" label=_("Create new project") style="secondary" icon_style="plus" justify_left=True workspace_uuid=workspace.uuid %} - {% endif %} - {% endif %} -
    diff --git a/projectify/workspace/templates/workspace/form_base.html b/projectify/workspace/templates/workspace/form_base.html index 8fa70e806..9f1dbde91 100644 --- a/projectify/workspace/templates/workspace/form_base.html +++ b/projectify/workspace/templates/workspace/form_base.html @@ -2,9 +2,6 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {% extends "dashboard_base.html" %} {% load i18n %} -{% block dashboard_projects %} - {% include "workspace/common/sidemenu/project_details.html" %} -{% endblock dashboard_projects %} {% block dashboard_content %}
    Date: Thu, 2 Jul 2026 20:41:49 +0900 Subject: [PATCH 18/41] Remove unused frontend_url ctx processor --- projectify/context_processors.py | 5 ----- projectify/premail/email.py | 7 +------ projectify/settings/base.py | 1 - 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/projectify/context_processors.py b/projectify/context_processors.py index 620bc71dc..cb2128b04 100644 --- a/projectify/context_processors.py +++ b/projectify/context_processors.py @@ -5,14 +5,9 @@ from typing import Mapping -from django.conf import settings from django.http import HttpRequest -def frontend_url(request: object) -> Mapping[str, str]: - """Add FRONTEND_URL to context.""" - return {"FRONTEND_URL": settings.FRONTEND_URL} - def show_go_to_dashboard(request: HttpRequest) -> Mapping[str, bool]: """Tell header nav that it can show "Go to dashboard".""" diff --git a/projectify/premail/email.py b/projectify/premail/email.py index 5e118366c..ecf9312d1 100644 --- a/projectify/premail/email.py +++ b/projectify/premail/email.py @@ -9,7 +9,6 @@ from django.template import loader from django.utils.safestring import SafeText, mark_safe -from projectify.context_processors import frontend_url from projectify.user.models import User Context = dict[str, Any] @@ -41,11 +40,7 @@ def get_body_template_path(self) -> str: def get_context(self) -> Context: """Get context. To override.""" - return { - **frontend_url(None), - "object": self.obj, - "addressee": self.addressee, - } + return {"object": self.obj, "addressee": self.addressee} def render_subject(self) -> SafeText: """Render subject.""" diff --git a/projectify/settings/base.py b/projectify/settings/base.py index 1ae37790c..26da04a66 100644 --- a/projectify/settings/base.py +++ b/projectify/settings/base.py @@ -356,7 +356,6 @@ class Base(Configuration): "APP_DIRS": True, "OPTIONS": { "context_processors": ( - "projectify.context_processors.frontend_url", # For header nav "Go to dashboard" link "projectify.context_processors.show_go_to_dashboard", "django.template.context_processors.csp", From 85bf9c843d688602359c27e444d0078ef17b9787 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 20:42:25 +0900 Subject: [PATCH 19/41] Settings: Add new Wiki feature flags --- projectify/settings/development.py | 4 +++- projectify/settings/test.py | 4 +++- projectify/settings/types.py | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/projectify/settings/development.py b/projectify/settings/development.py index 589bb4d89..690643a3f 100644 --- a/projectify/settings/development.py +++ b/projectify/settings/development.py @@ -44,7 +44,9 @@ def add_dev_middleware( class Development(Base): """Development configuration.""" - FEATURE_FLAGS = FeatureFlags(workspace_attachments=True) + FEATURE_FLAGS = FeatureFlags( + workspace_attachments=True, workspace_wikis=True + ) SITE_TITLE = "Local Development" diff --git a/projectify/settings/test.py b/projectify/settings/test.py index 0fab17136..2a49ec6fe 100644 --- a/projectify/settings/test.py +++ b/projectify/settings/test.py @@ -27,7 +27,9 @@ class Test(Base): """Test configuration.""" - FEATURE_FLAGS = FeatureFlags(workspace_attachments=True) + FEATURE_FLAGS = FeatureFlags( + workspace_attachments=True, workspace_wikis=True + ) SITE_TITLE = "Projectify Pytest" diff --git a/projectify/settings/types.py b/projectify/settings/types.py index c9bd4bc42..6ae4e1557 100644 --- a/projectify/settings/types.py +++ b/projectify/settings/types.py @@ -74,4 +74,6 @@ class FeatureFlags: """Projectify feature flags.""" """Set to True to Enable workspace attachments.""" - workspace_attachments: bool + workspace_attachments: bool = False + """Set to True to Enable workspace Wikis.""" + workspace_wikis: bool = False From 38d2415061ef7ae61776c6c7722ff8c0a791f1ac Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 20:42:07 +0900 Subject: [PATCH 20/41] Add new feature flag ctx processor --- projectify/context_processors.py | 31 +++++++++++++++++++++++++------ projectify/settings/base.py | 2 ++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/projectify/context_processors.py b/projectify/context_processors.py index cb2128b04..d0b66d2c5 100644 --- a/projectify/context_processors.py +++ b/projectify/context_processors.py @@ -3,17 +3,36 @@ # SPDX-FileCopyrightText: 2021, 2023 JWP Consulting GK """Projectify context processors.""" +from dataclasses import asdict from typing import Mapping from django.http import HttpRequest +from django.urls.resolvers import ResolverMatch +from projectify.lib.settings import get_settings +from projectify.settings.types import FeatureFlags def show_go_to_dashboard(request: HttpRequest) -> Mapping[str, bool]: """Tell header nav that it can show "Go to dashboard".""" - match = request.resolver_match - if not match: - return {} - if not match.app_names: - return {} - return {"show_go_to_dashboard": match.app_names[0] != "dashboard"} + match request.resolver_match: + case None: + return {} + case ResolverMatch(app_names=[]): + return {} + case ResolverMatch(app_names=["dashboard", *_]): + result = False + case ResolverMatch(app_names=[*_]): + result = True + return {"show_go_to_dashboard": result} + + +def feature_flags(request: HttpRequest) -> Mapping[str, Mapping[str, bool]]: + """Pass feature flags to frontend.""" + del request + settings = get_settings() + # defensive programming so that this function doesn't return some other + # important or secret stuff from the settings + match settings.FEATURE_FLAGS: + case FeatureFlags() as flags: + return {"feature_flags": asdict(flags)} diff --git a/projectify/settings/base.py b/projectify/settings/base.py index 26da04a66..d43303fed 100644 --- a/projectify/settings/base.py +++ b/projectify/settings/base.py @@ -358,6 +358,8 @@ class Base(Configuration): "context_processors": ( # For header nav "Go to dashboard" link "projectify.context_processors.show_go_to_dashboard", + # Pass state of feature flags into frontend + "projectify.context_processors.feature_flags", "django.template.context_processors.csp", "django.template.context_processors.debug", # `allauth` needs this from django From 669d04fae52de1be9f211d39f9d7b2bd72a4c882 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 21:00:19 +0900 Subject: [PATCH 21/41] Workspace: Refactor attachment quota calc --- projectify/workspace/selectors/quota.py | 52 ++++++++++++------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/projectify/workspace/selectors/quota.py b/projectify/workspace/selectors/quota.py index 0ea3d141e..d2919b1d3 100644 --- a/projectify/workspace/selectors/quota.py +++ b/projectify/workspace/selectors/quota.py @@ -45,14 +45,14 @@ class Limitations(TypedDict): "Attachment": 0, } + # Full workspace conditions are somewhat like this: # { # "Task": None, # "Project": None, # "TeamMemberAndInvite": workspace.customer.seats, +# "Attachment": 100 * 1024 * 1024 # } - - def get_workspace_quota_for_resource( resource: Resource, workspace: Workspace ) -> Limitation: @@ -68,46 +68,42 @@ def get_workspace_quota_for_resource( return trial_conditions[resource] case "full": pass - if resource == "TeamMemberAndInvite": - customer = workspace.customer - return customer.seats - return None + match resource: + case "TeamMemberAndInvite": + customer = workspace.customer + return customer.seats + case "Attachment": + return 100 * 1024 * 1024 + case _: + return None -def get_workspace_resource_count( - resource: Resource, workspace: Workspace -) -> int: - """Return resource count for a specific resource.""" +def workspace_quota_for(*, resource: Resource, workspace: Workspace) -> Quota: + """Return the quota within a workspace for a given resource.""" + limit = get_workspace_quota_for_resource(resource, workspace) + # Short circuit for no limit + if limit is None: + return Quota(current=None, limit=None, can_create_more=True) match resource: case "Task": - return Task.objects.filter(project__workspace=workspace).count() + current = Task.objects.filter(project__workspace=workspace).count() case "Project": - return workspace.project_set.count() + current = workspace.project_set.count() case "TeamMemberAndInvite": user_count = workspace.users.count() invite_count = workspace.teammemberinvite_set.filter( redeemed=False ).count() - return user_count + invite_count + current = user_count + invite_count case "Attachment": - match workspace.attachment_set.aggregate( + aggregate = workspace.attachment_set.aggregate( total_size=Sum("size", default=0) - ): + ) + match aggregate: case {"total_size": int() as result}: - return result + current = result case other: - raise RuntimeError( - f"Encountered unexpected result {other}" - ) - - -def workspace_quota_for(*, resource: Resource, workspace: Workspace) -> Quota: - """Return the quota within a workspace for a given resource.""" - limit = get_workspace_quota_for_resource(resource, workspace) - # Short circuit for no limit - if limit is None: - return Quota(current=None, limit=None, can_create_more=True) - current = get_workspace_resource_count(resource, workspace) + raise RuntimeError(f"Unexpected result {other}") return Quota(current=current, limit=limit, can_create_more=current < limit) From 3e25efd95c5ece5c8fb13d3059a583e95eaf73e3 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 21:29:25 +0900 Subject: [PATCH 22/41] Workspace: Add wiki (WIP) --- projectify/help/markdown_en/quota.md | 2 + projectify/rules.py | 6 + projectify/templates/dashboard_base.html | 5 + .../workspace/migrations/0087_wikipage.py | 75 ++++++++++ projectify/workspace/models.py | 29 ++++ projectify/workspace/selectors/quota.py | 9 +- projectify/workspace/selectors/wiki.py | 19 +++ projectify/workspace/services/wiki.py | 27 ++++ .../templates/workspace/wiki_page_detail.html | 18 +++ .../templates/workspace/wiki_page_update.html | 20 +++ projectify/workspace/types.py | 1 + projectify/workspace/urls.py | 19 +++ projectify/workspace/views/wiki.py | 132 ++++++++++++++++++ 13 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 projectify/workspace/migrations/0087_wikipage.py create mode 100644 projectify/workspace/selectors/wiki.py create mode 100644 projectify/workspace/services/wiki.py create mode 100644 projectify/workspace/templates/workspace/wiki_page_detail.html create mode 100644 projectify/workspace/templates/workspace/wiki_page_update.html create mode 100644 projectify/workspace/views/wiki.py diff --git a/projectify/help/markdown_en/quota.md b/projectify/help/markdown_en/quota.md index 47965572f..8f42770d2 100644 --- a/projectify/help/markdown_en/quota.md +++ b/projectify/help/markdown_en/quota.md @@ -12,6 +12,7 @@ Usage quotas determine how many items of a resource you can create within your workspace. Resources within your workspace are: - Team members and invitations +- Wiki pages - Projects - Tasks - Attachments @@ -38,6 +39,7 @@ Trial workspaces have the following usage quotas: - You can invite and add up to 1 additional team member, for a total of 2 including yourself. +- You can create up to 25 wiki pages - You can create up to 10 projects - You can create up to 1000 tasks - You can not upload any attachments diff --git a/projectify/rules.py b/projectify/rules.py index 983b93c02..2bb2d609a 100644 --- a/projectify/rules.py +++ b/projectify/rules.py @@ -115,6 +115,12 @@ def can_create_more( rules.add_perm("workspace.update_workspace", is_at_least_owner) rules.add_perm("workspace.delete_workspace", is_at_least_owner) +# Wiki pages +rules.add_perm("workspace.create_wiki_page", is_at_least_contributor) +rules.add_perm("workspace.read_wiki_page", is_at_least_observer) +rules.add_perm("workspace.update_wiki_page", is_at_least_contributor) +rules.add_perm("workspace.delete_wiki_page", is_at_least_maintainer) + # Team member invite rules.add_perm( "workspace.create_team_member_invite", diff --git a/projectify/templates/dashboard_base.html b/projectify/templates/dashboard_base.html index 942b6baa0..14df9a797 100644 --- a/projectify/templates/dashboard_base.html +++ b/projectify/templates/dashboard_base.html @@ -58,6 +58,11 @@ {% endif %}
+ {% if feature_flags.workspace_wikis %} +
+ {% anchor label=_("Wiki") href='dashboard:wiki:index' ws_uuid=workspace.uuid %} +
+ {% endif %} {% block dashboard_content %} {% endblock dashboard_content %} diff --git a/projectify/workspace/migrations/0087_wikipage.py b/projectify/workspace/migrations/0087_wikipage.py new file mode 100644 index 000000000..750ccc60c --- /dev/null +++ b/projectify/workspace/migrations/0087_wikipage.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Create Wiki page model.""" +# Generated by Django 6.0.5 on 2026-07-02 11:08 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import projectify.lib.models + + +class Migration(migrations.Migration): + """Migration.""" + + dependencies = [("workspace", "0086_attachment")] + + operations = [ + migrations.CreateModel( + name="WikiPage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + projectify.lib.models.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + projectify.lib.models.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, editable=False, unique=True + ), + ), + ( + "title", + models.CharField( + db_index=True, + help_text="Wiki page title", + max_length=255, + verbose_name="title", + ), + ), + ( + "content", + projectify.lib.models.RichTextField( + blank=True, null=True, verbose_name="content" + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="workspace.workspace", + ), + ), + ], + options={"unique_together": {("title", "workspace")}}, + ) + ] diff --git a/projectify/workspace/models.py b/projectify/workspace/models.py index 3a8978302..3a2270509 100644 --- a/projectify/workspace/models.py +++ b/projectify/workspace/models.py @@ -66,6 +66,7 @@ def __init__(self, *args: Any, **kwargs: Any): customer: RelatedField[None, "Customer"] # Related sets + wikipage_set: RelatedManager["WikiPage"] task_set: RelatedManager["Task"] project_set: RelatedManager["Project"] teammember_set: RelatedManager["TeamMember"] @@ -104,6 +105,34 @@ class Meta: ) +class WikiPage(BaseModelUUID): + workspace = models.ForeignKey["Workspace"]( + Workspace, on_delete=models.PROTECT + ) + title = models.CharField( + _("title"), + help_text=_("Wiki page title"), + max_length=255, + db_index=True, + ) + content = RichTextField( + verbose_name=_("content"), + blank=True, + null=True, + policy=settings.HTML_USER_POLICY, + ) + + def get_absolute_url(self) -> str: + """Return path to wiki page.""" + return reverse( + "dashboard:wiki:view", args=(self.workspace.uuid, self.title) + ) + + class Meta: + # Can only have one page with the same title per workspace + unique_together = ("title", "workspace") + + class Project(TitleDescriptionModel, BaseModelUUID): """Project.""" diff --git a/projectify/workspace/selectors/quota.py b/projectify/workspace/selectors/quota.py index d2919b1d3..8a9c7df57 100644 --- a/projectify/workspace/selectors/quota.py +++ b/projectify/workspace/selectors/quota.py @@ -23,7 +23,9 @@ from ..models import Task, Workspace -Resource = Literal["Task", "Project", "TeamMemberAndInvite", "Attachment"] +Resource = Literal[ + "Task", "Project", "TeamMemberAndInvite", "Attachment", "WikiPage" +] Limitation = Union[None, int] @@ -35,6 +37,7 @@ class Limitations(TypedDict): Project: Limitation TeamMemberAndInvite: Limitation Attachment: Limitation + WikiPage: Limitation trial_conditions: Limitations = { @@ -43,6 +46,7 @@ class Limitations(TypedDict): "TeamMemberAndInvite": 2, # No attachments for trial "Attachment": 0, + "WikiPage": 25, } @@ -85,6 +89,8 @@ def workspace_quota_for(*, resource: Resource, workspace: Workspace) -> Quota: if limit is None: return Quota(current=None, limit=None, can_create_more=True) match resource: + case "WikiPage": + current = workspace.wikipage_set.count() case "Task": current = Task.objects.filter(project__workspace=workspace).count() case "Project": @@ -114,6 +120,7 @@ def workspace_get_all_quotas(workspace: Workspace) -> WorkspaceQuota: workspace_status=customer_check_active_for_workspace( workspace=workspace ), + wiki_pages=mk(resource="WikiPage"), tasks=mk(resource="Task"), projects=mk(resource="Project"), team_members_and_invites=mk(resource="TeamMemberAndInvite"), diff --git a/projectify/workspace/selectors/wiki.py b/projectify/workspace/selectors/wiki.py new file mode 100644 index 000000000..67137e0a5 --- /dev/null +++ b/projectify/workspace/selectors/wiki.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Attachment selectors.""" + +from typing import Optional +from uuid import UUID + +from projectify.user.models import User + +from ..models import WikiPage + + +def wiki_find_by_workspace_and_page_title( + *, who: User, ws_uuid: UUID, title: str +) -> Optional[WikiPage]: + """Find wiki page by title and for user workspace.""" + return WikiPage.objects.filter( + workspace__uuid=ws_uuid, workspace__users=who, title=title + ).first() diff --git a/projectify/workspace/services/wiki.py b/projectify/workspace/services/wiki.py new file mode 100644 index 000000000..573caddff --- /dev/null +++ b/projectify/workspace/services/wiki.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Workspace Wiki services.""" + +from django.utils.translation import gettext_lazy as _ + +from projectify.lib.auth import validate_perm +from projectify.user.models import User + +from ..models import WikiPage, Workspace + + +def wiki_page_get_or_create_index( + *, workspace: Workspace, who: User +) -> WikiPage: + """Get wiki index or create a new one.""" + validate_perm("workspace.read_wiki_page", who, workspace) + # Assume that the first page is the index + match WikiPage.objects.filter( + workspace=workspace, workspace__users=who + ).first(): + case None: + return WikiPage.objects.create( + workspace=workspace, title=_("Index"), content="" + ) + case WikiPage() as page: + return page diff --git a/projectify/workspace/templates/workspace/wiki_page_detail.html b/projectify/workspace/templates/workspace/wiki_page_detail.html new file mode 100644 index 000000000..b5540435e --- /dev/null +++ b/projectify/workspace/templates/workspace/wiki_page_detail.html @@ -0,0 +1,18 @@ +{# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} +{% extends "dashboard_base.html" %} +{% load projectify %} +{% load rules %} +{% load i18n %} +{% block title %} + {% blocktrans with title=page.title %}{{ title }} - Projectify{% endblocktrans %} +{% endblock title %} +{% block dashboard_content %} +

{{ page.title }}

+ +
+ {{ page.content }} +
+{% endblock dashboard_content %} diff --git a/projectify/workspace/templates/workspace/wiki_page_update.html b/projectify/workspace/templates/workspace/wiki_page_update.html new file mode 100644 index 000000000..ffe8248d5 --- /dev/null +++ b/projectify/workspace/templates/workspace/wiki_page_update.html @@ -0,0 +1,20 @@ +{# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} +{% extends "dashboard_base.html" %} +{% load projectify %} +{% load rules %} +{% load i18n %} +{% block extrahead %} + + {{ form.media }} +{% endblock extrahead %} +{% block title %} + {% blocktrans with task_title=task.title %}{{ task_title }} task - Projectify{% endblocktrans %} +{% endblock title %} +{% block dashboard_content %} + + {% csrf_token %} + {{ form.as_p }} + + +{% endblock dashboard_content %} diff --git a/projectify/workspace/types.py b/projectify/workspace/types.py index 897c6dde7..4769754c4 100644 --- a/projectify/workspace/types.py +++ b/projectify/workspace/types.py @@ -25,6 +25,7 @@ class WorkspaceQuota: """Contain all workspace quota values.""" workspace_status: WorkspaceFeatures + wiki_pages: Quota tasks: Quota projects: Quota team_members_and_invites: Quota diff --git a/projectify/workspace/urls.py b/projectify/workspace/urls.py index 7ffb27447..84ae8320d 100644 --- a/projectify/workspace/urls.py +++ b/projectify/workspace/urls.py @@ -30,6 +30,11 @@ task_update_view, ) from projectify.workspace.views.team_member import team_member_picture +from projectify.workspace.views.wiki import ( + wiki_index, + wiki_page_edit, + wiki_page_view, +) from projectify.workspace.views.workspace import ( workspace_picture_view, workspace_search_view, @@ -157,6 +162,15 @@ "/attachments/", attachment_view, name="view" ), ) +wiki_patterns = ( + path("/wiki", wiki_index, name="index"), + path("/wiki/", wiki_page_view, name="view"), + path( + "/wiki//edit", + wiki_page_edit, + name="edit", + ), +) urlpatterns: UrlPatterns = ( path("", redirect_to_dashboard, name="dashboard"), # Avatar @@ -175,3 +189,8 @@ *urlpatterns, path("workspace/", include((attachment_patterns, "attachments"))), ) +if settings.FEATURE_FLAGS.workspace_wikis: + urlpatterns = ( + *urlpatterns, + path("workspace/", include((wiki_patterns, "wiki"))), + ) diff --git a/projectify/workspace/views/wiki.py b/projectify/workspace/views/wiki.py new file mode 100644 index 000000000..975f119e1 --- /dev/null +++ b/projectify/workspace/views/wiki.py @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Workspace wiki views.""" + +import logging +from typing import Any +from uuid import UUID + +from django import forms +from django.http import Http404, HttpResponse +from django.shortcuts import redirect, render +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_GET, require_http_methods + +from projectify.lib.forms import RichTextEditor +from projectify.lib.types import AuthenticatedHttpRequest +from projectify.lib.views import platform_view +from projectify.workspace.const import TASK_EDITOR_MIN_HEIGHT_CLASS + +from ..models import WikiPage, Workspace +from ..selectors.wiki import wiki_find_by_workspace_and_page_title +from ..selectors.workspace import workspace_find_by_workspace_uuid +from ..services.wiki import wiki_page_get_or_create_index + +logger = logging.getLogger(__name__) + + +@platform_view +@require_GET +def wiki_index( + request: AuthenticatedHttpRequest, ws_uuid: UUID +) -> HttpResponse: + """Return the default Wiki index page.""" + ws = workspace_find_by_workspace_uuid( + who=request.user, workspace_uuid=ws_uuid + ) + if ws is None: + raise Http404(_("Workspace not found")) + page = wiki_page_get_or_create_index(workspace=ws, who=request.user) + context = {"page": page, "workspace": page.workspace} + return render(request, "workspace/wiki_page_detail.html", context=context) + + +@platform_view +@require_GET +def wiki_page_view( + request: AuthenticatedHttpRequest, ws_uuid: UUID, page_title: str +) -> HttpResponse: + """Upload an image attachment to a workspace.""" + page = wiki_find_by_workspace_and_page_title( + ws_uuid=ws_uuid, who=request.user, title=page_title + ) + if page is None: + raise Http404( + _("Couldn't find wiki page {title}").format(title=page_title) + ) + context = {"page": page, "workspace": page.workspace} + return render(request, "workspace/wiki_page_detail.html", context=context) + + +class WikiPageForm(forms.ModelForm): + """Form for WikiPage.""" + + def __init__(self, *args: Any, workspace: Workspace, **kwargs: Any): + """Populate available assignees and optionally set autofocus.""" + super().__init__(*args, **kwargs) + editor = RichTextEditor( + heading_blocks=False, + upload_url=reverse( + "dashboard:attachments:create", args=(workspace.uuid,) + ), + attrs={ + "expand": True, + "placeholder": _("Enter a description for your task"), + "class": TASK_EDITOR_MIN_HEIGHT_CLASS, + "data-suggest-projects-url": reverse( + "dashboard:workspaces:suggest-links-project", + args=(workspace.uuid,), + ), + "data-suggest-links-url": ( + reverse( + "dashboard:workspaces:suggest-links-task", + args=(workspace.uuid,), + ), + ), + }, + ) + self.fields["content"].widget = editor + + class Meta: + """Meta.""" + + model = WikiPage + fields = "title", "content" + + +@platform_view +@require_http_methods(["GET", "POST"]) +def wiki_page_edit( + request: AuthenticatedHttpRequest, ws_uuid: UUID, page_title: str +) -> HttpResponse: + """Upload an image attachment to a workspace.""" + page = wiki_find_by_workspace_and_page_title( + ws_uuid=ws_uuid, who=request.user, title=page_title + ) + if page is None: + raise Http404( + _("Couldn't find wiki page {title}").format(title=page_title) + ) + match request.method: + case "POST": + form = WikiPageForm( + workspace=page.workspace, instance=page, data=request.POST + ) + if form.is_valid(): + form.save() + return redirect(page) + else: + status = 400 + case "GET": + form = WikiPageForm(workspace=page.workspace, instance=page) + status = 200 + case _: + raise RuntimeError("Shouldn't reach this") + context = {"page": page, "form": form, "workspace": page.workspace} + return render( + request, + "workspace/wiki_page_update.html", + status=status, + context=context, + ) From 163f4cbcfd37ade3ab660eadc2dff73676ca03a6 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 21:29:36 +0900 Subject: [PATCH 23/41] Improve anchor tag href matching --- projectify/templatetags/projectify.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/projectify/templatetags/projectify.py b/projectify/templatetags/projectify.py index 8d7670386..c489c717c 100644 --- a/projectify/templatetags/projectify.py +++ b/projectify/templatetags/projectify.py @@ -9,7 +9,7 @@ from django import template from django.contrib.staticfiles import finders from django.templatetags import static -from django.urls import NoReverseMatch, reverse +from django.urls import reverse from django.utils.html import format_html from django.utils.safestring import SafeText, mark_safe from django.utils.translation import gettext_lazy as _ @@ -54,15 +54,14 @@ def anchor( """ extra: Union[SafeText, str] target: Union[SafeText, str] - match href: - case "": + match href, args, kwargs: + case "", _, _: raise ValueError("Empty href supplied") - case str(): - try: - url = reverse(href, args=args, kwargs=kwargs) - except NoReverseMatch: - url = href - case model: + case str(), args, kwargs if len(args) == len(kwargs) == 0: + url = href + case str(), args, kwargs: + url = reverse(href, args=args, kwargs=kwargs) + case model, _, _: url = model.get_absolute_url() # TODO, if we have a reverse match, we don't have external URLs # We could switch all callers of the anchor function to use the route name From 9e2e1aeb644a453855e5b768624f76a8a4eb1b31 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 23:59:35 +0900 Subject: [PATCH 24/41] Refactor trix editor widget --- projectify/lib/forms.py | 2 +- projectify/static/prose/prose.js | 14 +++---- .../widgets}/trix-editor.html | 5 +++ projectify/workspace/forms.py | 37 +++++++++++++++++-- projectify/workspace/views/project.py | 29 ++------------- projectify/workspace/views/task.py | 27 +------------- projectify/workspace/views/workspace.py | 1 - 7 files changed, 50 insertions(+), 65 deletions(-) rename projectify/templates/{common => projectify/widgets}/trix-editor.html (78%) diff --git a/projectify/lib/forms.py b/projectify/lib/forms.py index f80d80451..11e6f6535 100644 --- a/projectify/lib/forms.py +++ b/projectify/lib/forms.py @@ -52,7 +52,7 @@ def get_image_format(file: UploadedFile) -> Optional[str]: class RichTextEditor(Textarea): """Rich text editor widget for prose's RichTextField.""" - template_name = "common/trix-editor.html" + template_name = "projectify/widgets/trix-editor.html" def __init__( self, diff --git a/projectify/static/prose/prose.js b/projectify/static/prose/prose.js index b36062676..aab378056 100644 --- a/projectify/static/prose/prose.js +++ b/projectify/static/prose/prose.js @@ -219,22 +219,18 @@ function configureToolbar(event) { if (buttonGroup === null) { throw new Error("Couldn't find Trix button group"); } - const suggestLinksUrl = editor.dataset.suggestLinksUrl; - const suggestProjectsUrl = editor.dataset.suggestProjectsUrl; - const suggestProjectsButton = createActionButton( + const { suggestLinksUrl, suggestProjectsUrl } = editor.dataset; + buttonGroup.appendChild(createActionButton( "Project", "Suggest projects", "x-suggest-project", - ); - buttonGroup.appendChild(suggestProjectsButton); + )); initializeLinkSuggestions(editor, suggestProjectsUrl, "project"); - - const suggestTasksButton = createActionButton( + buttonGroup.appendChild(createActionButton( "Task", "Suggest tasks", "x-suggest-task", - ); - buttonGroup.appendChild(suggestTasksButton); + )); initializeLinkSuggestions(editor, suggestLinksUrl, "task"); } editor.classList.add("initialized"); diff --git a/projectify/templates/common/trix-editor.html b/projectify/templates/projectify/widgets/trix-editor.html similarity index 78% rename from projectify/templates/common/trix-editor.html rename to projectify/templates/projectify/widgets/trix-editor.html index f9afd4d51..0f3799d1d 100644 --- a/projectify/templates/common/trix-editor.html +++ b/projectify/templates/projectify/widgets/trix-editor.html @@ -17,4 +17,9 @@ As long as your forms base off form_base, they'll have a dialog for link suggestions. + +I've removed the dialogs to make this widget fit inside a

tag. This is +useful when you're rendering a form with .as_p() HTML forbids block +elements like

inside

tags. At the same time, the MDN docs +recommend putting form elements inside

tags {% endcomment %} diff --git a/projectify/workspace/forms.py b/projectify/workspace/forms.py index 799065167..93141bde1 100644 --- a/projectify/workspace/forms.py +++ b/projectify/workspace/forms.py @@ -9,11 +9,43 @@ from django import forms from django.core.exceptions import ValidationError from django.db.models import Model, QuerySet +from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from projectify.lib.forms import SafeImageField +from projectify.lib.forms import RichTextEditor, SafeImageField from projectify.lib.settings import get_settings -from projectify.workspace.models import TeamMember + +from .const import TASK_EDITOR_MIN_HEIGHT_CLASS +from .models import TeamMember, Workspace + +settings = get_settings() + + +class WorkspaceRichTextEditor(RichTextEditor): + """Rich text editor that takes in a Workspace.""" + + def __init__(self, workspace: Workspace, *args: Any, **kwargs: Any): + """Initialize the widget with optional heading_blocks and upload_url attributes.""" + attrs = { + "expand": True, + "placeholder": _("Enter a description for your task"), + "class": TASK_EDITOR_MIN_HEIGHT_CLASS, + "data-suggest-projects-url": reverse( + "dashboard:workspaces:suggest-links-project", + args=(workspace.uuid,), + ), + "data-suggest-links-url": reverse( + "dashboard:workspaces:suggest-links-task", + args=(workspace.uuid,), + ), + } + super().__init__( + heading_blocks=False, + upload_url=reverse( + "dashboard:attachments:create", args=(workspace.uuid,) + ), + attrs=attrs, + ) @dataclass @@ -130,7 +162,6 @@ class AttachmentUploadForm(forms.Form): def __init__(self, *args: Any, **kwargs: Any): """Initialize form with SafeImageField configured from settings.""" super().__init__(*args, **kwargs) - settings = get_settings() self.fields["file"] = SafeImageField( allowed_file_types=settings.BLOG_ALLOWED_FILE_TYPES, allowed_file_size=settings.BLOG_ALLOWED_FILE_SIZE, diff --git a/projectify/workspace/views/project.py b/projectify/workspace/views/project.py index 2233cff90..70abb9a2d 100644 --- a/projectify/workspace/views/project.py +++ b/projectify/workspace/views/project.py @@ -13,16 +13,15 @@ from django.forms import ValidationError from django.http import Http404, HttpResponse from django.shortcuts import redirect, render -from django.urls import reverse from django.utils.html import escape from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods -from projectify.lib.forms import RichTextEditor, populate_form_with_errors +from projectify.lib.forms import populate_form_with_errors from projectify.lib.htmx import HttpResponseClientRefresh from projectify.lib.types import AuthenticatedHttpRequest from projectify.lib.views import platform_view -from projectify.workspace.const import TASK_EDITOR_MIN_HEIGHT_CLASS +from projectify.workspace.forms import WorkspaceRichTextEditor from projectify.workspace.utils import strip_first_paragraph from ..models import Project, Task, TeamMember, Workspace @@ -214,29 +213,7 @@ class ProjectForm(forms.Form): def __init__(self, *args: Any, workspace: Workspace, **kwargs: Any): """Populate available assignees and optionally set autofocus.""" super().__init__(*args, **kwargs) - # XXX duplicated from projectify/workspace/views/task.py:TaskForm - editor = RichTextEditor( - heading_blocks=False, - upload_url=reverse( - "dashboard:attachments:create", args=(workspace.uuid,) - ), - attrs={ - "expand": True, - "placeholder": _("Enter a description for your task"), - "class": TASK_EDITOR_MIN_HEIGHT_CLASS, - "data-suggest-projects-url": reverse( - "dashboard:workspaces:suggest-links-project", - args=(workspace.uuid,), - ), - "data-suggest-links-url": ( - reverse( - "dashboard:workspaces:suggest-links-task", - args=(workspace.uuid,), - ), - ), - }, - ) - self.fields["description"].widget = editor + self.fields["description"].widget = WorkspaceRichTextEditor(workspace) @require_http_methods(["GET", "POST"]) diff --git a/projectify/workspace/views/task.py b/projectify/workspace/views/task.py index 46a9a6cdd..a8b0c664b 100644 --- a/projectify/workspace/views/task.py +++ b/projectify/workspace/views/task.py @@ -12,24 +12,22 @@ from django.http import HttpResponse from django.http.response import Http404 from django.shortcuts import redirect, render -from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods, require_POST -from projectify.lib.forms import RichTextEditor from projectify.lib.htmx import ( HttpResponseClientRedirect, HttpResponseClientRefresh, ) from projectify.lib.types import AuthenticatedHttpRequest from projectify.lib.views import platform_view +from projectify.workspace.forms import WorkspaceRichTextEditor from projectify.workspace.selectors.project import ( ProjectDetailQuerySet, project_find_by_project_uuid, ) from projectify.workspace.utils import extract_first_paragraph_text -from ..const import TASK_EDITOR_MIN_HEIGHT_CLASS from ..models import Task, Workspace from ..selectors.task import TaskDetailQuerySet, task_find_by_task_uuid from ..selectors.team_member import team_member_find_for_workspace @@ -118,28 +116,7 @@ def __init__( ) self.order_fields(["description", "assignee", "due_date"]) - editor = RichTextEditor( - heading_blocks=False, - upload_url=reverse( - "dashboard:attachments:create", args=(workspace.uuid,) - ), - attrs={ - "expand": True, - "placeholder": _("Enter a description for your task"), - "class": TASK_EDITOR_MIN_HEIGHT_CLASS, - "data-suggest-projects-url": reverse( - "dashboard:workspaces:suggest-links-project", - args=(workspace.uuid,), - ), - "data-suggest-links-url": ( - reverse( - "dashboard:workspaces:suggest-links-task", - args=(workspace.uuid,), - ), - ), - }, - ) - self.fields["description"].widget = editor + self.fields["description"].widget = WorkspaceRichTextEditor(workspace) if focus_field is None: pass diff --git a/projectify/workspace/views/workspace.py b/projectify/workspace/views/workspace.py index 339414c58..c0f9c083a 100644 --- a/projectify/workspace/views/workspace.py +++ b/projectify/workspace/views/workspace.py @@ -757,7 +757,6 @@ def workspace_suggest_links( workspace = workspace_find_by_workspace_uuid( workspace_uuid=workspace_uuid, who=request.user ) - assert link_type in {"project", "task"}, f"{link_type=} unrecognnized" if workspace is None: raise Http404( _("Could not find workspace with UUID {workspace_uuid}").format( From 6bac42eea3902a5a5f8b570cc85e05b81f1be8d1 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Fri, 3 Jul 2026 00:00:56 +0900 Subject: [PATCH 25/41] Bin: Fix djlint re-run --- bin/test.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/test.sh b/bin/test.sh index df6ae1fe8..ac5c13de7 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -28,7 +28,9 @@ then echo "djlint --check ran successfully" else echo "There was an error running djlint --check" - uv run djlint --reformat "$target" + if uv run djlint --reformat "$target"; then + echo "djlint reformatted" + fi if ! uv run djlint --check "$target"; then echo "Couldn't fix with djlint --reformat" exit 1 From bf2388ca123dc2c533d104cc9afeddc25bdd0952 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Fri, 3 Jul 2026 00:01:59 +0900 Subject: [PATCH 26/41] Workspace: Continue Wiki Pages --- projectify/static/prose/prose.js | 9 ++ projectify/workspace/forms.py | 8 + projectify/workspace/selectors/wiki.py | 16 +- projectify/workspace/selectors/workspace.py | 20 ++- .../templates/workspace/wiki_page_detail.html | 10 +- .../templates/workspace/wiki_page_new.html | 23 +++ .../templates/workspace/wiki_page_update.html | 11 +- .../workspace/workspace_suggest_links.html | 7 + projectify/workspace/urls.py | 10 ++ projectify/workspace/views/wiki.py | 151 ++++++++++++------ projectify/workspace/views/workspace.py | 5 + 11 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 projectify/workspace/templates/workspace/wiki_page_new.html diff --git a/projectify/static/prose/prose.js b/projectify/static/prose/prose.js index aab378056..862582e09 100644 --- a/projectify/static/prose/prose.js +++ b/projectify/static/prose/prose.js @@ -232,6 +232,15 @@ function configureToolbar(event) { "x-suggest-task", )); initializeLinkSuggestions(editor, suggestLinksUrl, "task"); + const { suggestWikiUrl } = editor.dataset; + if (suggestWikiUrl !== undefined) { + buttonGroup.appendChild(createActionButton( + "Wiki Page", + "Suggest Wiki pages", + "x-suggest-wiki", + )); + initializeLinkSuggestions(editor, suggestWikiUrl, "wiki"); + } } editor.classList.add("initialized"); } diff --git a/projectify/workspace/forms.py b/projectify/workspace/forms.py index 93141bde1..2d47e9d8d 100644 --- a/projectify/workspace/forms.py +++ b/projectify/workspace/forms.py @@ -39,6 +39,14 @@ def __init__(self, workspace: Workspace, *args: Any, **kwargs: Any): args=(workspace.uuid,), ), } + if settings.FEATURE_FLAGS.workspace_wikis: + attrs = { + **attrs, + "data-suggest-wiki-url": reverse( + "dashboard:workspaces:suggest-links-wiki", + args=(workspace.uuid,), + ), + } super().__init__( heading_blocks=False, upload_url=reverse( diff --git a/projectify/workspace/selectors/wiki.py b/projectify/workspace/selectors/wiki.py index 67137e0a5..6adad4feb 100644 --- a/projectify/workspace/selectors/wiki.py +++ b/projectify/workspace/selectors/wiki.py @@ -5,15 +5,27 @@ from typing import Optional from uuid import UUID +from django.db.models import QuerySet + from projectify.user.models import User from ..models import WikiPage +WikiPageDetailQuerySet = WikiPage.objects.select_related( + "workspace" +).prefetch_related("workspace__project_set", "workspace__teammember_set") + def wiki_find_by_workspace_and_page_title( - *, who: User, ws_uuid: UUID, title: str + *, + who: User, + ws_uuid: UUID, + title: str, + qs: QuerySet[WikiPage] | None = None, ) -> Optional[WikiPage]: """Find wiki page by title and for user workspace.""" - return WikiPage.objects.filter( + if qs is None: + qs = WikiPage.objects + return qs.filter( workspace__uuid=ws_uuid, workspace__users=who, title=title ).first() diff --git a/projectify/workspace/selectors/workspace.py b/projectify/workspace/selectors/workspace.py index 9268a1215..f5f5fbf7e 100644 --- a/projectify/workspace/selectors/workspace.py +++ b/projectify/workspace/selectors/workspace.py @@ -20,7 +20,14 @@ from projectify.corporate.types import CustomerSubscriptionStatus from projectify.user.models import User -from ..models import Project, Task, TeamMember, TeamMemberInvite, Workspace +from ..models import ( + Project, + Task, + TeamMember, + TeamMemberInvite, + WikiPage, + Workspace, +) logger = logging.getLogger(__name__) @@ -126,6 +133,7 @@ class WorkspaceSearchResults: projects: QuerySet[Project] tasks: QuerySet[Task] + wiki_pages: QuerySet[WikiPage] def workspace_search( @@ -173,4 +181,12 @@ def workspace_search( ) project_qs = Project.objects.filter(project_q) - return WorkspaceSearchResults(projects=project_qs, tasks=tasks) + wiki_page_q = workspace_filter + if query is not None: + # TODO add full text search with vectors + wiki_page_q &= Q(title__icontains=query) + wiki_page_qs = WikiPage.objects.filter(wiki_page_q) + + return WorkspaceSearchResults( + projects=project_qs, tasks=tasks, wiki_pages=wiki_page_qs + ) diff --git a/projectify/workspace/templates/workspace/wiki_page_detail.html b/projectify/workspace/templates/workspace/wiki_page_detail.html index b5540435e..53852fa7e 100644 --- a/projectify/workspace/templates/workspace/wiki_page_detail.html +++ b/projectify/workspace/templates/workspace/wiki_page_detail.html @@ -8,11 +8,11 @@ {% blocktrans with title=page.title %}{{ title }} - Projectify{% endblocktrans %} {% endblock title %} {% block dashboard_content %} -

{{ page.title }}

-
- {{ page.content }} + +

{{ page.title }}

+
{{ page.content }}
{% endblock dashboard_content %} diff --git a/projectify/workspace/templates/workspace/wiki_page_new.html b/projectify/workspace/templates/workspace/wiki_page_new.html new file mode 100644 index 000000000..6b785b21d --- /dev/null +++ b/projectify/workspace/templates/workspace/wiki_page_new.html @@ -0,0 +1,23 @@ +{# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} +{% extends "workspace/form_base.html" %} +{% load projectify %} +{% load rules %} +{% load i18n %} +{% block extrahead %} + + {{ form.media }} +{% endblock extrahead %} +{% block title %} + {% blocktrans %}New {{ page_title }} - Projectify{% endblocktrans %} +{% endblock title %} +{% block form_header %} + {% blocktrans %}{{ page_title }}{% endblocktrans %} +{% endblock form_header %} +{% block form_content %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock form_content %} diff --git a/projectify/workspace/templates/workspace/wiki_page_update.html b/projectify/workspace/templates/workspace/wiki_page_update.html index ffe8248d5..2833c4729 100644 --- a/projectify/workspace/templates/workspace/wiki_page_update.html +++ b/projectify/workspace/templates/workspace/wiki_page_update.html @@ -1,6 +1,6 @@ {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "dashboard_base.html" %} +{% extends "workspace/form_base.html" %} {% load projectify %} {% load rules %} {% load i18n %} @@ -9,12 +9,15 @@ {{ form.media }} {% endblock extrahead %} {% block title %} - {% blocktrans with task_title=task.title %}{{ task_title }} task - Projectify{% endblocktrans %} + {% blocktrans with title=page.title %}Editing {{ title }} - Projectify{% endblocktrans %} {% endblock title %} -{% block dashboard_content %} +{% block form_header %} + {% blocktrans with title=page.title %}{{ title }}{% endblocktrans %} +{% endblock form_header %} +{% block form_content %}
{% csrf_token %} {{ form.as_p }}
-{% endblock dashboard_content %} +{% endblock form_content %} diff --git a/projectify/workspace/templates/workspace/workspace_suggest_links.html b/projectify/workspace/templates/workspace/workspace_suggest_links.html index 34acb2422..2e271ba77 100644 --- a/projectify/workspace/templates/workspace/workspace_suggest_links.html +++ b/projectify/workspace/templates/workspace/workspace_suggest_links.html @@ -15,7 +15,10 @@ {% icon "circle" size=4 inline=True %} {% elif search_type == "project" %} {% icon "folder" size=4 inline=True %} + {% elif search_type == "wiki" %} + {% icon "folder" size=4 inline=True %} {% else %} + UNKNOWN {% endif %} {{ result.0 }} @@ -25,6 +28,8 @@

{% translate "No tasks found" %}

{% elif search_type == "project" %}

{% translate "No projects found" %}

+ {% elif search_type == "wiki" %} +

{% translate "No wiki page found" %}

{% else %}

{% translate "No tasks or projects found" %}

{% endif %} @@ -37,6 +42,8 @@

{% translate "Link to task" %} {% elif search_type == "project" %} {% translate "Link to project" %} + {% elif search_type == "wiki" %} + {% translate "Link to wiki page" %} {% else %} {% translate "Link to task or project" %} {% endif %} diff --git a/projectify/workspace/urls.py b/projectify/workspace/urls.py index 84ae8320d..bc2af8e72 100644 --- a/projectify/workspace/urls.py +++ b/projectify/workspace/urls.py @@ -110,6 +110,16 @@ name="suggest-links-project", ), ) +if settings.FEATURE_FLAGS.workspace_wikis: + workspace_patterns = ( + *workspace_patterns, + path( + "/suggest-links/wiki", + workspace_suggest_links, + {"link_type": "wiki"}, + name="suggest-links-wiki", + ), + ) if get_settings().STRIPE_CONFIG is None: logger.info( "Stripe configuration not present. " diff --git a/projectify/workspace/views/wiki.py b/projectify/workspace/views/wiki.py index 975f119e1..155b62ab3 100644 --- a/projectify/workspace/views/wiki.py +++ b/projectify/workspace/views/wiki.py @@ -9,18 +9,22 @@ from django import forms from django.http import Http404, HttpResponse from django.shortcuts import redirect, render -from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_GET, require_http_methods -from projectify.lib.forms import RichTextEditor from projectify.lib.types import AuthenticatedHttpRequest from projectify.lib.views import platform_view -from projectify.workspace.const import TASK_EDITOR_MIN_HEIGHT_CLASS +from projectify.workspace.forms import WorkspaceRichTextEditor from ..models import WikiPage, Workspace -from ..selectors.wiki import wiki_find_by_workspace_and_page_title -from ..selectors.workspace import workspace_find_by_workspace_uuid +from ..selectors.wiki import ( + WikiPageDetailQuerySet, + wiki_find_by_workspace_and_page_title, +) +from ..selectors.workspace import ( + WorkspaceDetailQuerySet, + workspace_find_by_workspace_uuid, +) from ..services.wiki import wiki_page_get_or_create_index logger = logging.getLogger(__name__) @@ -33,29 +37,16 @@ def wiki_index( ) -> HttpResponse: """Return the default Wiki index page.""" ws = workspace_find_by_workspace_uuid( - who=request.user, workspace_uuid=ws_uuid + who=request.user, workspace_uuid=ws_uuid, qs=WorkspaceDetailQuerySet ) if ws is None: raise Http404(_("Workspace not found")) page = wiki_page_get_or_create_index(workspace=ws, who=request.user) - context = {"page": page, "workspace": page.workspace} - return render(request, "workspace/wiki_page_detail.html", context=context) - - -@platform_view -@require_GET -def wiki_page_view( - request: AuthenticatedHttpRequest, ws_uuid: UUID, page_title: str -) -> HttpResponse: - """Upload an image attachment to a workspace.""" - page = wiki_find_by_workspace_and_page_title( - ws_uuid=ws_uuid, who=request.user, title=page_title - ) - if page is None: - raise Http404( - _("Couldn't find wiki page {title}").format(title=page_title) - ) - context = {"page": page, "workspace": page.workspace} + context = { + "page": page, + "workspace": page.workspace, + "projects": page.workspace.project_set.all(), + } return render(request, "workspace/wiki_page_detail.html", context=context) @@ -64,35 +55,86 @@ class WikiPageForm(forms.ModelForm): def __init__(self, *args: Any, workspace: Workspace, **kwargs: Any): """Populate available assignees and optionally set autofocus.""" + if self.instance: + self.page_title = self.instance.page_title + elif "page_title" in kwargs: + self.page_title = kwargs.pop("page_title") + else: + raise ValueError("Must call with page_title") super().__init__(*args, **kwargs) - editor = RichTextEditor( - heading_blocks=False, - upload_url=reverse( - "dashboard:attachments:create", args=(workspace.uuid,) - ), - attrs={ - "expand": True, - "placeholder": _("Enter a description for your task"), - "class": TASK_EDITOR_MIN_HEIGHT_CLASS, - "data-suggest-projects-url": reverse( - "dashboard:workspaces:suggest-links-project", - args=(workspace.uuid,), - ), - "data-suggest-links-url": ( - reverse( - "dashboard:workspaces:suggest-links-task", - args=(workspace.uuid,), - ), - ), - }, - ) - self.fields["content"].widget = editor + self.workspace = workspace + self.fields["content"].widget = WorkspaceRichTextEditor(self.workspace) + + def save(self, *args: Any, **kwargs: Any) -> WikiPage: + """Set workspace, title before saving.""" + self.instance.workspace = self.workspace + self.instance.title = self.page_title + result: WikiPage = super().save(*args, **kwargs) + return result class Meta: """Meta.""" model = WikiPage - fields = "title", "content" + fields = ("content",) + + +@platform_view +@require_http_methods(["GET", "POST"]) +def wiki_page_view( + request: AuthenticatedHttpRequest, ws_uuid: UUID, page_title: str +) -> HttpResponse: + """Upload an image attachment to a workspace.""" + page = wiki_find_by_workspace_and_page_title( + ws_uuid=ws_uuid, + who=request.user, + title=page_title, + qs=WikiPageDetailQuerySet, + ) + if page is None: + ws = workspace_find_by_workspace_uuid( + who=request.user, + workspace_uuid=ws_uuid, + qs=WorkspaceDetailQuerySet, + ) + if ws is None: + raise Http404(_("Workspace not found")) + match request.method: + case "POST": + form = WikiPageForm(workspace=ws, data=request.POST) + if form.is_valid(): + page = form.save(page_title=page_title) + return redirect(page) + else: + status = 400 + case "GET": + form = WikiPageForm(workspace=ws) + status = 200 + case _: + raise RuntimeError("Shouldn't reach this") + context = { + "form": form, + "workspace": ws, + "projects": ws.project_set.all(), + "page_title": page_title, + } + template = "workspace/wiki_page_new.html" + else: + match request.method: + case "GET": + status = 200 + case _: + status = 405 + # TODO show flash that the user can't POSt on an existing + # wiki page + context = { + "page": page, + "workspace": page.workspace, + # XXX slow + "projects": page.workspace.project_set.all(), + } + template = "workspace/wiki_page_detail.html" + return render(request, template, status=status, context=context) @platform_view @@ -102,7 +144,10 @@ def wiki_page_edit( ) -> HttpResponse: """Upload an image attachment to a workspace.""" page = wiki_find_by_workspace_and_page_title( - ws_uuid=ws_uuid, who=request.user, title=page_title + ws_uuid=ws_uuid, + who=request.user, + title=page_title, + qs=WikiPageDetailQuerySet, ) if page is None: raise Http404( @@ -114,7 +159,7 @@ def wiki_page_edit( workspace=page.workspace, instance=page, data=request.POST ) if form.is_valid(): - form.save() + form.save(page_title=page_title) return redirect(page) else: status = 400 @@ -123,7 +168,13 @@ def wiki_page_edit( status = 200 case _: raise RuntimeError("Shouldn't reach this") - context = {"page": page, "form": form, "workspace": page.workspace} + context = { + "page": page, + "form": form, + "workspace": page.workspace, + # XXX slow + "projects": page.workspace.project_set.all(), + } return render( request, "workspace/wiki_page_update.html", diff --git a/projectify/workspace/views/workspace.py b/projectify/workspace/views/workspace.py index c0f9c083a..d63633b07 100644 --- a/projectify/workspace/views/workspace.py +++ b/projectify/workspace/views/workspace.py @@ -784,6 +784,11 @@ def workspace_suggest_links( (task.title, task.get_absolute_url()) for task in results.tasks ] + case "wiki": + suggestions = [ + (page.title, page.get_absolute_url()) + for page in results.wiki_pages + ] case other: return HttpResponseBadRequest( _("Unknown query type {type}").format(type=other) From b5be582a99f6052894e3a653b02d302c633402c7 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Fri, 3 Jul 2026 00:02:29 +0900 Subject: [PATCH 27/41] Workspace: Removse unused code --- .../templates/workspace/project_detail.html | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/projectify/workspace/templates/workspace/project_detail.html b/projectify/workspace/templates/workspace/project_detail.html index 6ec536383..6e13c6796 100644 --- a/projectify/workspace/templates/workspace/project_detail.html +++ b/projectify/workspace/templates/workspace/project_detail.html @@ -45,27 +45,8 @@ window.location.href = e.detail.requestConfig.path; } - function toggleTaskMenu(event) { - const anchor = event.target.closest('[data-task-menu-toggle]'); - if (!anchor) { - return; - } - const taskUuid = anchor.dataset.taskMenuToggle; - const menu = document.getElementById(`task-${taskUuid}-menu`); - if (!menu) { - throw new Error(`Couldn't find task menu for task ${taskUuid}`); - } - if (menu.classList.contains('hidden')) { - return; - } - event.preventDefault(); - menu.classList.add('hidden'); - menu.textContent = ''; - } - document.addEventListener('htmx:afterSwap', attachCloseButtonListener); document.addEventListener('htmx:beforeRequest', checkScreenSize); - document.addEventListener('htmx:beforeRequest', toggleTaskMenu); {% endblock extrahead %} {% partialdef quick_add_task %} @@ -97,8 +78,7 @@ {% partialdef taskrow %} + aria-labelledby="task-{{ task.uuid }}-title"> {% if can_update_task %}
- {% endpartialdef taskrow %} {% partialdef project_tasks %} {% has_perm "workspace.create_task" user project.workspace as can_create_task %} From e6fd6c19dd5de0ce70e914cb944c4197d8138007 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Fri, 3 Jul 2026 00:02:39 +0900 Subject: [PATCH 28/41] Workspace: Update query counts --- .../workspace/test/views/test_project.py | 8 +++--- .../workspace/test/views/test_workspace.py | 25 ++++++++----------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/projectify/workspace/test/views/test_project.py b/projectify/workspace/test/views/test_project.py index 179f34853..d780045c9 100644 --- a/projectify/workspace/test/views/test_project.py +++ b/projectify/workspace/test/views/test_project.py @@ -35,7 +35,7 @@ def test_get_project_detail( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test GETting the project detail page.""" - with django_assert_num_queries(20): + with django_assert_num_queries(21): response = user_client.get(resource_url) assert response.status_code == 200 assert project.title in response.content.decode() @@ -122,15 +122,15 @@ def test_mark_task_done( # check # Gone down from 29 -> 28 # Gone down from 28 -> 26 - # Gone down from 26 -> 22 - with django_assert_num_queries(30): + # Gone up from 26 -> 31 + with django_assert_num_queries(31): response = user_client.post(resource_url, data) assert response.status_code == 200 task.refresh_from_db() assert task.done is not None data = {"action": "mark_task_done", "task_uuid": t_id, "done": "false"} - with django_assert_num_queries(30): + with django_assert_num_queries(31): response = user_client.post(resource_url, data) assert response.status_code == 200 task.refresh_from_db() diff --git a/projectify/workspace/test/views/test_workspace.py b/projectify/workspace/test/views/test_workspace.py index 292570493..84ef06ff8 100644 --- a/projectify/workspace/test/views/test_workspace.py +++ b/projectify/workspace/test/views/test_workspace.py @@ -494,8 +494,7 @@ def test_get_quota_page( ) -> None: """Test getting the quota page.""" # Gone up from 12 -> 13 due to permission checks in sidemenu - # Gone down from 13 -> 12 - with django_assert_num_queries(12): + with django_assert_num_queries(13): response = user_client.get(resource_url) assert response.status_code == 200 @@ -516,8 +515,7 @@ def test_get_quota_page_no_subscription( customer_cancel_subscription(customer=team_member.workspace.customer) # Gone up from 16 -> 17 due to permission checks in sidemenu # Gone down from 17 -> 15 - # Gone down from 15 -> 14 - with django_assert_num_queries(14): + with django_assert_num_queries(15): response = user_client.get(resource_url) assert response.status_code == 200 # These quotas should be listed @@ -566,7 +564,7 @@ def test_with_unpaid_customer( ) -> None: """Assert that an unpaid customer can't edit their billing settings.""" data = {"action": "checkout", "seats": 5} - with django_assert_num_queries(17): + with django_assert_num_queries(18): response = user_client.post(resource_url, data=data) assert response.status_code == 302 assert response.headers["Location"] == "https://www.example.com" @@ -613,7 +611,7 @@ def test_posting_normal_data( ) -> None: """Test we can get a redirect when posting valid checkout data.""" data = {"action": "checkout", "seats": "99"} - with django_assert_num_queries(17): + with django_assert_num_queries(18): response = user_client.post(resource_url, data=data) assert response.status_code == 302, response.content.decode() assert response.headers["Location"] == "https://www.example.com" @@ -668,8 +666,7 @@ def test_get_with_unpaid_customer( """Test GET request with unpaid customer shows billing form.""" # Gone up from 16 -> 17 due to permission checks in sidemenu # Gone down from 17 -> 15 - # Gone down from 15 -> 14 - with django_assert_num_queries(14): + with django_assert_num_queries(15): response = user_client.get(resource_url) assert response.status_code == 200 assert b"Use a coupon code" in response.content @@ -684,8 +681,7 @@ def test_get_with_paying_customer( ) -> None: """Test GET request with paying customer shows billing info.""" # Gone up from 12 -> 13 due to permission checks in sidemenu - # Gone down from 13 -> 12 - with django_assert_num_queries(12): + with django_assert_num_queries(13): response = user_client.get(resource_url) assert response.status_code == 200 assert b"You have a paid workspace" in response.content @@ -705,18 +701,17 @@ def test_redeeming_invalid_code( resource_url: str, team_member: TeamMember, django_assert_num_queries: DjangoAssertNumQueries, - workspace: Workspace, unpaid_customer: Customer, ) -> None: """Test that nothing bad happens with an invalid coupon code.""" + workspace = team_member.workspace active = customer_check_active_for_workspace(workspace=workspace) assert active == "trial" data = {"action": "redeem_coupon", "code": "foo"} # Gone up from 21 -> 22 due to permission checks in sidemenu # Gone up from 22 -> 23 # Gone down from 23 -> 19 - # Gone down from 19 -> 18 - with django_assert_num_queries(18): + with django_assert_num_queries(19): res = user_client.post(resource_url, data=data) assert res.status_code == 400 assert "No coupon is available for this code" in res.content.decode() @@ -731,15 +726,15 @@ def test_redeeming_valid_code( team_member: TeamMember, coupon: Coupon, django_assert_num_queries: DjangoAssertNumQueries, - workspace: Workspace, unpaid_customer: Customer, ) -> None: """Test that workspace subscription is activated correctly.""" + workspace = team_member.workspace assert unpaid_customer.seats != 20 active = customer_check_active_for_workspace(workspace=workspace) assert active == "trial" data = {"action": "redeem_coupon", "code": coupon.code} - with django_assert_num_queries(23): + with django_assert_num_queries(24): response = user_client.post(resource_url, data=data) assert response.status_code == 302 From 71483d0aa35db54c6eb2c21d64e1f98fa0a86dbf Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 19:20:26 +0200 Subject: [PATCH 29/41] Workspace: Fix wiki page form --- projectify/workspace/views/wiki.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/projectify/workspace/views/wiki.py b/projectify/workspace/views/wiki.py index 155b62ab3..5cce99f33 100644 --- a/projectify/workspace/views/wiki.py +++ b/projectify/workspace/views/wiki.py @@ -55,12 +55,15 @@ class WikiPageForm(forms.ModelForm): def __init__(self, *args: Any, workspace: Workspace, **kwargs: Any): """Populate available assignees and optionally set autofocus.""" - if self.instance: - self.page_title = self.instance.page_title - elif "page_title" in kwargs: - self.page_title = kwargs.pop("page_title") - else: - raise ValueError("Must call with page_title") + match kwargs: + case {"instance": WikiPage() as instance}: + self.page_title = instance.title + case {"page_title": str()}: + self.page_title = kwargs.pop("page_title") + case other: + raise ValueError( + f"Must call with page_title, received {other}" + ) super().__init__(*args, **kwargs) self.workspace = workspace self.fields["content"].widget = WorkspaceRichTextEditor(self.workspace) @@ -101,14 +104,16 @@ def wiki_page_view( raise Http404(_("Workspace not found")) match request.method: case "POST": - form = WikiPageForm(workspace=ws, data=request.POST) + form = WikiPageForm( + workspace=ws, page_title=page_title, data=request.POST + ) if form.is_valid(): - page = form.save(page_title=page_title) + page = form.save() return redirect(page) else: status = 400 case "GET": - form = WikiPageForm(workspace=ws) + form = WikiPageForm(workspace=ws, page_title=page_title) status = 200 case _: raise RuntimeError("Shouldn't reach this") @@ -159,7 +164,7 @@ def wiki_page_edit( workspace=page.workspace, instance=page, data=request.POST ) if form.is_valid(): - form.save(page_title=page_title) + form.save() return redirect(page) else: status = 400 From 664f01360d8dfff9c317de02b7aa788743297bba Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Thu, 2 Jul 2026 19:20:16 +0200 Subject: [PATCH 30/41] Refactor prose.js --- projectify/static/prose/prose.js | 148 +++++++++++++++---------------- 1 file changed, 74 insertions(+), 74 deletions(-) diff --git a/projectify/static/prose/prose.js b/projectify/static/prose/prose.js index 862582e09..03b34d0ae 100644 --- a/projectify/static/prose/prose.js +++ b/projectify/static/prose/prose.js @@ -68,65 +68,67 @@ document.addEventListener("DOMContentLoaded", () => { }; }); -function initializeLinkSuggestions(editor, suggestLinksUrl, type) { - const dialog = document.getElementById("prose-link-suggestions-dialog"); - if (dialog === null) { - throw new Error("Couldn't find link suggestions dialog"); +function insertLink(trixEditor, dialog, event) { + if (!event.target.dataset.url) { + console.error("Received click event for a button with no data-url set"); + return; } - async function showWidget(trixEditor) { - const selectedRange = trixEditor.getSelectedRange(); - const selectedText = selectedRange[0] !== selectedRange[1]; - const searchString = selectedText - ? `?search=${trixEditor.getDocument().toString().slice(selectedRange[0], selectedRange[1])}` - : ""; + const { url, title } = event.target.dataset; + const [start, end] = trixEditor.getSelectedRange(); + const textSelected = start !== end; + if (textSelected) { + // Potential sink for XSS + // Trix relies on DOMPurify + trixEditor.activateAttribute("href", url); + } else { + const start = trixEditor.getPosition(); + trixEditor.insertString(title); + const end = trixEditor.getPosition(); + trixEditor.setSelectedRange([start, end]); + trixEditor.activateAttribute("href", url); + trixEditor.setSelectedRange([end, end]); + } + dialog.close(); +} - await htmx.ajax("GET", `${suggestLinksUrl}${searchString}`, { - target: dialog, - swap: "innerHTML", - }); +async function showWidget(trixEditor, suggestLinksUrl, dialog) { + const selectedRange = trixEditor.getSelectedRange(); + const selectedText = selectedRange[0] !== selectedRange[1]; + const searchString = selectedText + ? `?search=${trixEditor.getDocument().toString().slice(selectedRange[0], selectedRange[1])}` + : ""; - dialog.showModal(); + await htmx.ajax("GET", `${suggestLinksUrl}${searchString}`, { + target: dialog, + swap: "innerHTML", + }); - const closeButton = dialog.querySelector('[name="close"]'); - if (closeButton === null) { - throw new Error("Couldn't find close button"); - } - closeButton.addEventListener("click", () => { - dialog.close(); - }); - - // Find list of results inside dialog. The results hold url and - // title data- attributes - const results = document.getElementById("prose-link-suggestions-results"); - if (results === null) { - throw new Error("Couldn't find #prose-link-suggestions-results"); - } - results.addEventListener("click", (event) => { - if (!event.target.dataset.url) { - console.error( - "Received click event for a button with no data-url set", - ); - return; - } - - const { url, title } = event.target.dataset; - const [start, end] = trixEditor.getSelectedRange(); - const textSelected = start !== end; - if (textSelected) { - // Potential sink for XSS - // Trix relies on DOMPurify - trixEditor.activateAttribute("href", url); - } else { - const start = trixEditor.getPosition(); - trixEditor.insertString(title); - const end = trixEditor.getPosition(); - trixEditor.setSelectedRange([start, end]); - trixEditor.activateAttribute("href", url); - trixEditor.setSelectedRange([end, end]); - } - dialog.close(); - }); + dialog.showModal(); + + const closeButton = dialog.querySelector('[name="close"]'); + if (closeButton === null) { + throw new Error("Couldn't find close button"); + } + // XXX find out why this breaks + // closeButton.addEventListener("click", dialog.close); + // error: + // Uncaught TypeError: 'close' called on an object that does not implement interface HTMLDialogElement. + closeButton.addEventListener("click", () => dialog.close()); + + // Find list of results inside dialog. The results hold url and + // title data- attributes + const results = document.getElementById("prose-link-suggestions-results"); + if (results === null) { + throw new Error("Couldn't find #prose-link-suggestions-results"); + } + results.addEventListener("click", insertLink.bind(null, trixEditor, dialog)); +} + +function initializeLinkSuggestions(editor, suggestLinksUrl, type) { + const dialog = document.getElementById("prose-link-suggestions-dialog"); + if (dialog === null) { + throw new Error("Couldn't find link suggestions dialog"); } editor.addEventListener("trix-action-invoke", (event) => { @@ -138,7 +140,7 @@ function initializeLinkSuggestions(editor, suggestLinksUrl, type) { return; } const { editor: trixEditor } = event.target; - showWidget(trixEditor); + showWidget(trixEditor, suggestLinksUrl, dialog); }); } /*! SPDX-SnippetBegin @@ -199,9 +201,9 @@ function configureToolbar(event) { uploadFile(uploadUrl, event.attachment), ); } else { - editor.addEventListener("trix-file-accept", function (event) { - event.preventDefault(); - }); + editor.addEventListener("trix-file-accept", (event) => + event.preventDefault(), + ); const fileToolsGroup = toolbarElement.querySelector( ".trix-button-group--file-tools", ); @@ -219,26 +221,24 @@ function configureToolbar(event) { if (buttonGroup === null) { throw new Error("Couldn't find Trix button group"); } - const { suggestLinksUrl, suggestProjectsUrl } = editor.dataset; - buttonGroup.appendChild(createActionButton( - "Project", - "Suggest projects", - "x-suggest-project", - )); + const { suggestLinksUrl, suggestProjectsUrl } = editor.dataset; + buttonGroup.appendChild( + createActionButton("Project", "Suggest projects", "x-suggest-project"), + ); initializeLinkSuggestions(editor, suggestProjectsUrl, "project"); - buttonGroup.appendChild(createActionButton( - "Task", - "Suggest tasks", - "x-suggest-task", - )); + buttonGroup.appendChild( + createActionButton("Task", "Suggest tasks", "x-suggest-task"), + ); initializeLinkSuggestions(editor, suggestLinksUrl, "task"); const { suggestWikiUrl } = editor.dataset; if (suggestWikiUrl !== undefined) { - buttonGroup.appendChild(createActionButton( - "Wiki Page", - "Suggest Wiki pages", - "x-suggest-wiki", - )); + buttonGroup.appendChild( + createActionButton( + "Wiki Page", + "Suggest Wiki pages", + "x-suggest-wiki", + ), + ); initializeLinkSuggestions(editor, suggestWikiUrl, "wiki"); } } From c494525113c5f0d49bbbee7cf3004ea5ca23f1e6 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Mon, 13 Jul 2026 13:46:17 +0200 Subject: [PATCH 31/41] Clean empty


in rich text --- projectify/lib/utils.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/projectify/lib/utils.py b/projectify/lib/utils.py index 915ea3b30..e9a4893f1 100644 --- a/projectify/lib/utils.py +++ b/projectify/lib/utils.py @@ -12,7 +12,8 @@ from django.templatetags import static from django.utils.safestring import SafeString, mark_safe -from justhtml import JustHTML, SanitizationPolicy +from justhtml import Decide, JustHTML, Node, PruneEmpty, SanitizationPolicy +from justhtml.transforms_spec import DecideAction from markdown import Markdown from PIL import Image @@ -24,13 +25,25 @@ settings = get_settings() +def has_only_br(node: Node) -> DecideAction: + """Return DROP when this Node is of the form
.""" + match node.children: + case [Node(name="br")]: + return DecideAction.DROP + case _: + return DecideAction.KEEP + + def clean_rich_text( unsafe_html: str, policy: SanitizationPolicy = settings.HTML_USER_POLICY ) -> SafeString: """Clean the text for rich text content.""" # https://github.com/EmilStenstrom/justhtml/blob/main/docs/sanitization.md sanitized_html: str = JustHTML( - unsafe_html, policy=policy, fragment=True + unsafe_html, + policy=policy, + fragment=True, + transforms=[Decide("p", has_only_br), PruneEmpty("*")], ).to_html(pretty=False) # TODO strip empty blocks # TODO strip repeated whitespace " " -> " " From 20f90a06bb8b682b5e6a00369799679ba952dc5c Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Wed, 15 Jul 2026 12:44:40 +0200 Subject: [PATCH 32/41] Docs: Describe how to use partialdef --- docs/styleguide.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/styleguide.md b/docs/styleguide.md index 767b5079a..237e9448b 100644 --- a/docs/styleguide.md +++ b/docs/styleguide.md @@ -307,6 +307,42 @@ def log_in_first(request: AuthenticatedHttpRequest) -> HttpResponse: ... - To style templates, Projectify uses [Tailwind version 3](https://v3.tailwindcss.com/). Refer to the tailwind configuration at `tailwind.config.js`. +### Partial defs + +Use Django `partialdef` like so: + +```html +{% partialdef button %} + +{% endpartialdef %} + +{% partial button %} +``` + +With `context={"name": "world"}`, this becomes the following: + +```html + +``` + +Pass arguments with `{% with %}`: + +```html +{% partialdef button %} + +{% endpartialdef %} + +{% with name="foobar" %} +{% partial button %} +{% endwith +``` + +This becomes the following: + +```html + +``` + ## Template includes ### Submit button From cf46dd00a046e2bb8c5b3f9b846dcc37bf7fbfbe Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Wed, 15 Jul 2026 12:45:56 +0200 Subject: [PATCH 33/41] Clean up dashboard_base.html --- projectify/static/css/dist/styles.css | 24 ++++---------------- projectify/templates/dashboard_base.html | 29 ++++++++++++------------ 2 files changed, 18 insertions(+), 35 deletions(-) diff --git a/projectify/static/css/dist/styles.css b/projectify/static/css/dist/styles.css index 53cff9cb9..58c2aac06 100644 --- a/projectify/static/css/dist/styles.css +++ b/projectify/static/css/dist/styles.css @@ -1840,11 +1840,6 @@ video { background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)); } -.bg-primary-hover { - --tw-bg-opacity: 1; - background-color: rgb(30 64 175 / var(--tw-bg-opacity, 1)); -} - .object-contain { -o-object-fit: contain; object-fit: contain; @@ -1921,11 +1916,6 @@ video { padding-right: 1rem; } -.px-5 { - padding-left: 1.25rem; - padding-right: 1.25rem; -} - .px-6 { padding-left: 1.5rem; padding-right: 1.5rem; @@ -2414,10 +2404,6 @@ video { display: grid; } - .md\:max-w-xs { - max-width: 20rem; - } - .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2426,6 +2412,10 @@ video { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .md\:grid-cols-\[1fr_3fr\] { + grid-template-columns: 1fr 3fr; + } + .md\:flex-row { flex-direction: row; } @@ -2581,9 +2571,3 @@ video { grid-template-columns: repeat(3, minmax(0, 1fr)); } } - -@media (min-width: 1536px) { - .\32xl\:max-w-md { - max-width: 28rem; - } -} diff --git a/projectify/templates/dashboard_base.html b/projectify/templates/dashboard_base.html index 14df9a797..562fd454e 100644 --- a/projectify/templates/dashboard_base.html +++ b/projectify/templates/dashboard_base.html @@ -6,14 +6,14 @@ {% load rules %} {% block body %} {% include "common/navigation/header/dashboard.html" %} -
+
{% block dashboard_content %} {% endblock dashboard_content %} From 1c88aca45ab977ff6aaf4ff122efc9ed0018897a Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Fri, 17 Jul 2026 22:47:37 +0200 Subject: [PATCH 34/41] Clean up task_detail.html --- projectify/workspace/templates/workspace/task_detail.html | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/projectify/workspace/templates/workspace/task_detail.html b/projectify/workspace/templates/workspace/task_detail.html index 8bc680332..3730f8f06 100644 --- a/projectify/workspace/templates/workspace/task_detail.html +++ b/projectify/workspace/templates/workspace/task_detail.html @@ -75,11 +75,9 @@ class="max-w-4xl min-w-0 grow flex flex-col gap-4 p-4 sm:px-8">
{% partial breadcrumbs %} -
- {% if can_update_task %} - {% go_to_action "dashboard:tasks:update" label=_("Edit") title=_("Edit this task") task_uuid=task.uuid %} - {% endif %} -
+ {% if can_update_task %} + {% go_to_action "dashboard:tasks:update" label=_("Edit") title=_("Edit this task") task_uuid=task.uuid %} + {% endif %}
{% partial task_panel %}

From 3129afd4176dcc3ff7fcfe2c2d40afb6b4aeba9c Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Fri, 17 Jul 2026 22:47:45 +0200 Subject: [PATCH 35/41] Clean up wiki page --- .../templates/workspace/wiki_page_detail.html | 11 ++++++----- .../workspace/templates/workspace/wiki_page_new.html | 9 +++------ .../templates/workspace/wiki_page_update.html | 7 ++----- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/projectify/workspace/templates/workspace/wiki_page_detail.html b/projectify/workspace/templates/workspace/wiki_page_detail.html index 53852fa7e..2317473e5 100644 --- a/projectify/workspace/templates/workspace/wiki_page_detail.html +++ b/projectify/workspace/templates/workspace/wiki_page_detail.html @@ -8,11 +8,12 @@ {% blocktrans with title=page.title %}{{ title }} - Projectify{% endblocktrans %} {% endblock title %} {% block dashboard_content %} -
- -

{{ page.title }}

+
+
+

{{ page.title }}

+ {% go_to_action "dashboard:wiki:edit" label=_("Edit") title=_("Edit this page") ws_uuid=workspace.uuid page_title=page.title %} +
{{ page.content }}
{% endblock dashboard_content %} diff --git a/projectify/workspace/templates/workspace/wiki_page_new.html b/projectify/workspace/templates/workspace/wiki_page_new.html index 6b785b21d..ed6adcce5 100644 --- a/projectify/workspace/templates/workspace/wiki_page_new.html +++ b/projectify/workspace/templates/workspace/wiki_page_new.html @@ -12,12 +12,9 @@ {% blocktrans %}New {{ page_title }} - Projectify{% endblocktrans %} {% endblock title %} {% block form_header %} - {% blocktrans %}{{ page_title }}{% endblocktrans %} + {% blocktrans with title=page.title %}{{ title }}{% endblocktrans %} + {% include "projectify/forms/submit.html" with text=_("Save") small=True form_name="main" %} {% endblock form_header %} {% block form_content %} - - {% csrf_token %} - {{ form.as_p }} - - + {{ form.as_p }} {% endblock form_content %} diff --git a/projectify/workspace/templates/workspace/wiki_page_update.html b/projectify/workspace/templates/workspace/wiki_page_update.html index 2833c4729..13b06a142 100644 --- a/projectify/workspace/templates/workspace/wiki_page_update.html +++ b/projectify/workspace/templates/workspace/wiki_page_update.html @@ -13,11 +13,8 @@ {% endblock title %} {% block form_header %} {% blocktrans with title=page.title %}{{ title }}{% endblocktrans %} + {% include "projectify/forms/submit.html" with text=_("Save") small=True form_name="main" %} {% endblock form_header %} {% block form_content %} -
- {% csrf_token %} - {{ form.as_p }} - -
+ {{ form.as_p }} {% endblock form_content %} From 68ab64e774d92b04abb3e723645d671b3623030b Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Fri, 17 Jul 2026 22:55:41 +0200 Subject: [PATCH 36/41] Fix some anchor consistency issues --- projectify/storefront/templates/storefront/index.html | 6 ++---- projectify/templatetags/projectify.py | 7 +++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/projectify/storefront/templates/storefront/index.html b/projectify/storefront/templates/storefront/index.html index 42e51fc27..bd7463491 100644 --- a/projectify/storefront/templates/storefront/index.html +++ b/projectify/storefront/templates/storefront/index.html @@ -195,8 +195,7 @@

{% trans "We never sell your data. Ev

{% trans "Our platform fully complies with GDPR regulations, amongst others, so you can rest assured that your private information stays private." %}

- {% url "storefront:privacy" as pp_url %} - {% anchor pp_url label=_("Learn more about privacy and Projectify") %} + {% anchor "storefront:privacy" label=_("Learn more about privacy and Projectify") %}

{% picture src="privacy.png" alt=_("An illustration showing our mascot Poly safekeeping your data") %} @@ -212,8 +211,7 @@

{% trans "Projectify is 100 % Free So

{% trans "We respect your freedom and privacy and provide you with the source code under a Free Software license. The Projectify application is licensed under the GNU Affero General Public License (AGPL) version 3.0 or later." %}

- {% url "storefront:free_software" as learnmore_url %} - {% anchor learnmore_url label=_("Learn more about Free Software") %} + {% anchor "storefront:free_software" label=_("Learn more about Free Software") %}

diff --git a/projectify/templatetags/projectify.py b/projectify/templatetags/projectify.py index c489c717c..2d222906b 100644 --- a/projectify/templatetags/projectify.py +++ b/projectify/templatetags/projectify.py @@ -9,7 +9,7 @@ from django import template from django.contrib.staticfiles import finders from django.templatetags import static -from django.urls import reverse +from django.urls import NoReverseMatch, reverse from django.utils.html import format_html from django.utils.safestring import SafeText, mark_safe from django.utils.translation import gettext_lazy as _ @@ -58,7 +58,10 @@ def anchor( case "", _, _: raise ValueError("Empty href supplied") case str(), args, kwargs if len(args) == len(kwargs) == 0: - url = href + try: + url = reverse(href) + except NoReverseMatch: + url = href case str(), args, kwargs: url = reverse(href, args=args, kwargs=kwargs) case model, _, _: From d182122bcf864e0261464e2a7860f2e771774109 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Wed, 22 Jul 2026 20:49:19 +0200 Subject: [PATCH 37/41] Improve workspace view tests --- .../workspace/test/views/test_workspace.py | 63 +++++++++---------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/projectify/workspace/test/views/test_workspace.py b/projectify/workspace/test/views/test_workspace.py index 84ef06ff8..fc134ac33 100644 --- a/projectify/workspace/test/views/test_workspace.py +++ b/projectify/workspace/test/views/test_workspace.py @@ -148,53 +148,47 @@ def test_get_filter_by_unassigned( class TestWorkspacePictureView: """Test workspace_picture_view function.""" + @pytest.fixture + def resource_url( + self, team_member: TeamMember, uploaded_file: File + ) -> str: + """Return URL to this view.""" + team_member.workspace.picture = cast(FileDescriptor, uploaded_file) + team_member.workspace.save() + return reverse( + "dashboard:workspaces:picture", args=(team_member.workspace.uuid,) + ) + def test_authorized_access( self, user_client: Client, - team_member: TeamMember, - uploaded_file: File, django_assert_num_queries: DjangoAssertNumQueries, + resource_url: str, ) -> None: """Test that authorized team members can access workspace picture.""" - workspace = team_member.workspace - workspace.picture = cast(FileDescriptor, uploaded_file) - workspace.save() - url = reverse("dashboard:workspaces:picture", args=(workspace.uuid,)) with django_assert_num_queries(3): - assert user_client.get(url).status_code == 200 + assert user_client.get(resource_url).status_code == 200 def test_unauthorized_access( - self, - user_client: Client, - unrelated_workspace: Workspace, - uploaded_file: File, + self, unrelated_user_client: Client, resource_url: str ) -> None: """Test that non-team members cannot access workspace picture.""" - unrelated_workspace.picture = cast(FileDescriptor, uploaded_file) - unrelated_workspace.save() - url = reverse( - "dashboard:workspaces:picture", args=(unrelated_workspace.uuid,) - ) - assert user_client.get(url).status_code == 404 + assert unrelated_user_client.get(resource_url).status_code == 404 class TestWorkspaceSettings: """Test django workspace settings view.""" @pytest.fixture - def resource_url(self, workspace: Workspace) -> str: + def resource_url(self, team_member: TeamMember) -> str: """Return URL to this view.""" - return reverse("dashboard:workspaces:settings", args=(workspace.uuid,)) + return reverse("dashboard:workspaces:settings", args=(team_member.workspace.uuid,)) def test_get_form( - self, - user: User, - user_client: Client, - resource_url: str, - workspace: Workspace, - team_member: TeamMember, + self, user_client: Client, resource_url: str, team_member: TeamMember ) -> None: """Test GETting the page.""" + workspace = team_member.workspace response = user_client.get(resource_url) assert response.status_code == 200 assert workspace.title.encode() in response.content @@ -204,11 +198,11 @@ def test_update_workspace_and_title( user_client: Client, resource_url: str, uploaded_file: File, - workspace: Workspace, team_member: TeamMember, django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test updating both title and workspace picture.""" + workspace = team_member.workspace workspace.picture = cast(FileDescriptor, None) workspace.save() assert not workspace.picture @@ -437,16 +431,16 @@ class TestWorkspaceSettingsTeamMemberUpdate: @pytest.fixture def resource_url( - self, workspace: Workspace, other_team_member: TeamMember + self, team_member: TeamMember, other_team_member: TeamMember ) -> str: """Return URL to this view.""" return reverse( "dashboard:workspaces:team-member-update", - args=(workspace.uuid, other_team_member.uuid), + args=(team_member.workspace.uuid, other_team_member.uuid), ) def test_get_update_form( - self, user_client: Client, resource_url: str, team_member: TeamMember + self, user_client: Client, resource_url: str ) -> None: """Test getting the team member update form.""" response = user_client.get(resource_url) @@ -456,7 +450,6 @@ def test_update_team_member_role( self, user_client: Client, resource_url: str, - team_member: TeamMember, other_team_member: TeamMember, django_assert_num_queries: DjangoAssertNumQueries, ) -> None: @@ -481,15 +474,16 @@ class TestWorkspaceSettingsQuota: """Test workspace quota settings view.""" @pytest.fixture - def resource_url(self, workspace: Workspace) -> str: + def resource_url(self, team_member: TeamMember) -> str: """Return URL to this view.""" - return reverse("dashboard:workspaces:quota", args=(workspace.uuid,)) + return reverse( + "dashboard:workspaces:quota", args=(team_member.workspace.uuid,) + ) def test_get_quota_page( self, user_client: Client, resource_url: str, - team_member: TeamMember, django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test getting the quota page.""" @@ -723,13 +717,12 @@ def test_redeeming_valid_code( self, user_client: Client, resource_url: str, - team_member: TeamMember, coupon: Coupon, django_assert_num_queries: DjangoAssertNumQueries, unpaid_customer: Customer, ) -> None: """Test that workspace subscription is activated correctly.""" - workspace = team_member.workspace + workspace = unpaid_customer.workspace assert unpaid_customer.seats != 20 active = customer_check_active_for_workspace(workspace=workspace) assert active == "trial" From efa5f4441b1077811704ab993989a39862fa2e60 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Wed, 22 Jul 2026 20:49:38 +0200 Subject: [PATCH 38/41] Improve wiki --- projectify/conftest.py | 10 + projectify/static/css/dist/styles.css | 9 + projectify/templates/dashboard_base.html | 3 +- projectify/workspace/selectors/wiki.py | 13 +- .../workspace/wiki_recent_changes.html | 31 +++ projectify/workspace/test/views/test_wiki.py | 197 ++++++++++++++++++ projectify/workspace/urls.py | 6 + projectify/workspace/views/wiki.py | 121 ++++++----- 8 files changed, 339 insertions(+), 51 deletions(-) create mode 100644 projectify/workspace/templates/workspace/wiki_recent_changes.html create mode 100644 projectify/workspace/test/views/test_wiki.py diff --git a/projectify/conftest.py b/projectify/conftest.py index b3169da1e..1db6bc2f4 100644 --- a/projectify/conftest.py +++ b/projectify/conftest.py @@ -60,6 +60,7 @@ TeamMember, TeamMemberInvite, TeamMemberRoles, + WikiPage, Workspace, ) from projectify.workspace.selectors.team_member import ( @@ -74,6 +75,7 @@ from projectify.workspace.services.team_member_invite import ( team_member_invite_create, ) +from projectify.workspace.services.wiki import wiki_page_get_or_create_index from projectify.workspace.services.workspace import ( workspace_add_user, workspace_create, @@ -532,3 +534,11 @@ def post(faker: Faker, now: datetime, post_content: PostContent) -> Post: def null_uuid() -> UUID: """Create an all-null UUID.""" return UUID(int=0) + + +@pytest.fixture +def wiki_page(workspace: Workspace, team_member: TeamMember) -> WikiPage: + """Return a wiki index page.""" + return wiki_page_get_or_create_index( + workspace=workspace, who=team_member.user + ) diff --git a/projectify/static/css/dist/styles.css b/projectify/static/css/dist/styles.css index 58c2aac06..dc08a2c15 100644 --- a/projectify/static/css/dist/styles.css +++ b/projectify/static/css/dist/styles.css @@ -1629,6 +1629,10 @@ video { align-items: center; } +.items-baseline { + align-items: baseline; +} + .justify-start { justify-content: flex-start; } @@ -2036,6 +2040,11 @@ video { line-height: 1.75rem; } +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + .text-xl { font-size: 1.25rem; line-height: 1.75rem; diff --git a/projectify/templates/dashboard_base.html b/projectify/templates/dashboard_base.html index 562fd454e..8713cfcb5 100644 --- a/projectify/templates/dashboard_base.html +++ b/projectify/templates/dashboard_base.html @@ -54,7 +54,8 @@

{% trans "Projects" %}

{% trans "Wiki" %}

    -
  • {% anchor label=_("Wiki home") href='dashboard:wiki:index' ws_uuid=workspace.uuid %}
  • +
  • {% anchor label=_("Main page") href='dashboard:wiki:index' ws_uuid=workspace.uuid %}
  • +
  • {% anchor label=_("Recent changes") href='dashboard:wiki:recent-changes' ws_uuid=workspace.uuid %}
{% endif %} diff --git a/projectify/workspace/selectors/wiki.py b/projectify/workspace/selectors/wiki.py index 6adad4feb..5a3a79d84 100644 --- a/projectify/workspace/selectors/wiki.py +++ b/projectify/workspace/selectors/wiki.py @@ -9,13 +9,24 @@ from projectify.user.models import User -from ..models import WikiPage +from ..models import WikiPage, Workspace WikiPageDetailQuerySet = WikiPage.objects.select_related( "workspace" ).prefetch_related("workspace__project_set", "workspace__teammember_set") +def wiki_find_recent_changes( + *, who: User, workspace: "Workspace", qs: QuerySet[WikiPage] | None = None +) -> QuerySet[WikiPage]: + """Find recently modified wiki pages for a workspace, newest first.""" + if qs is None: + qs = WikiPage.objects + return qs.filter(workspace=workspace, workspace__users=who).order_by( + "-modified" + ) + + def wiki_find_by_workspace_and_page_title( *, who: User, diff --git a/projectify/workspace/templates/workspace/wiki_recent_changes.html b/projectify/workspace/templates/workspace/wiki_recent_changes.html new file mode 100644 index 000000000..876d2789a --- /dev/null +++ b/projectify/workspace/templates/workspace/wiki_recent_changes.html @@ -0,0 +1,31 @@ +{# SPDX-FileCopyrightText: 2026 JWP Consulting GK #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} +{% extends "dashboard_base.html" %} +{% load projectify %} +{% load i18n %} +{% block title %} + {% blocktrans with title=workspace.title %}Recent changes - {{ title }} - Projectify{% endblocktrans %} +{% endblock title %} +{% block dashboard_content %} +
+
+

{% translate "Recent changes" %}

+ {% go_to_action "dashboard:wiki:index" label=_("Wiki home") ws_uuid=workspace.uuid %} +
+ {% if pages %} +
    + {% for page in pages %} +
  • + {% anchor href="dashboard:wiki:view" label=page.title ws_uuid=workspace.uuid page_title=page.title as link %} + {% blocktranslate with modified=page.modified %} + {{ link }} - Modified {{ modified }} + {% endblocktranslate %} +
  • + {% endfor %} +
+ {% else %} +

{% translate "No wiki pages with recent changes." %}

+ {% endif %} +
+{% endblock dashboard_content %} diff --git a/projectify/workspace/test/views/test_wiki.py b/projectify/workspace/test/views/test_wiki.py new file mode 100644 index 000000000..758efa2d3 --- /dev/null +++ b/projectify/workspace/test/views/test_wiki.py @@ -0,0 +1,197 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Test wiki views.""" + +from uuid import UUID + +from django.test.client import Client +from django.urls import reverse + +import pytest + +from pytest_types import DjangoAssertNumQueries + +from ...models import TeamMember, WikiPage, Workspace + +pytestmark = pytest.mark.django_db + + +class TestWikiIndexView: + """Test wiki index view.""" + + @pytest.fixture + def resource_url(self, team_member: TeamMember) -> str: + """Return URL to this view.""" + w = team_member.workspace.uuid + return reverse("dashboard:wiki:index", args=(w,)) + + def test_get( + self, + user_client: Client, + resource_url: str, + django_assert_num_queries: DjangoAssertNumQueries, + ) -> None: + """Test GETting the wiki index page.""" + with django_assert_num_queries(13): + assert user_client.get(resource_url).status_code == 200 + + def test_get_unrelated_user( + self, unrelated_user_client: Client, resource_url: str + ) -> None: + """Test that unrelated users can't access the wiki index.""" + assert unrelated_user_client.get(resource_url).status_code == 404 + + def test_workspace_not_found( + self, user_client: Client, null_uuid: UUID + ) -> None: + """Test accessing wiki index for non-existent workspace.""" + url = reverse("dashboard:wiki:index", args=(null_uuid,)) + assert user_client.get(url).status_code == 404 + + +class TestWikiRecentChangesView: + """Test wiki recent changes view.""" + + @pytest.fixture + def resource_url(self, team_member: TeamMember) -> str: + """Return URL to this view.""" + w = team_member.workspace.uuid + return reverse("dashboard:wiki:recent-changes", args=(w,)) + + def test_get( + self, + user_client: Client, + resource_url: str, + wiki_page: WikiPage, + django_assert_num_queries: DjangoAssertNumQueries, + ) -> None: + """Test GETting the recent changes page.""" + with django_assert_num_queries(9): + response = user_client.get(resource_url) + assert response.status_code == 200 + assert wiki_page.title in response.content.decode() + + def test_get_no_pages( + self, user_client: Client, resource_url: str + ) -> None: + """Test recent changes page with no wiki pages.""" + response = user_client.get(resource_url) + assert response.status_code == 200 + assert b"No wiki pages found." in response.content + + def test_get_unrelated_user( + self, unrelated_user_client: Client, resource_url: str + ) -> None: + """Test that unrelated users can't access recent changes.""" + assert unrelated_user_client.get(resource_url).status_code == 404 + + def test_workspace_not_found( + self, user_client: Client, null_uuid: UUID + ) -> None: + """Test accessing recent changes for non-existent workspace.""" + url = reverse("dashboard:wiki:recent-changes", args=(null_uuid,)) + assert user_client.get(url).status_code == 404 + + +class TestWikiPageView: + """Test wiki page view.""" + + @pytest.fixture + def resource_url( + self, team_member: TeamMember, wiki_page: WikiPage + ) -> str: + """Return URL to this view.""" + w = team_member.workspace.uuid + return reverse("dashboard:wiki:view", args=(w, wiki_page.title)) + + def test_get_existing_page( + self, + user_client: Client, + resource_url: str, + wiki_page: WikiPage, + django_assert_num_queries: DjangoAssertNumQueries, + ) -> None: + """Test GETting an existing wiki page.""" + with django_assert_num_queries(7): + response = user_client.get(resource_url) + assert response.status_code == 200 + assert wiki_page.title in response.content.decode() + + def test_get_new_page( + self, user_client: Client, workspace: Workspace + ) -> None: + """Test GETting a non-existent wiki page redirects to edit view.""" + url = reverse("dashboard:wiki:view", args=(workspace.uuid, "b")) + response = user_client.get(url) + assert response.status_code == 302 + assert response["Location"] == reverse( + "dashboard:wiki:edit", args=(workspace.uuid, "b") + ) + + def test_get_unrelated_user( + self, unrelated_user_client: Client, resource_url: str + ) -> None: + """Test that unrelated users can't access wiki pages.""" + assert unrelated_user_client.get(resource_url).status_code == 404 + + def test_workspace_not_found( + self, user_client: Client, wiki_page: WikiPage, null_uuid: UUID + ) -> None: + """Test accessing a wiki page for a non-existent workspace.""" + url = reverse("dashboard:wiki:view", args=(null_uuid, wiki_page.title)) + assert user_client.get(url).status_code == 404 + + +class TestWikiPageEditView: + """Test wiki page edit view.""" + + @pytest.fixture + def resource_url( + self, team_member: TeamMember, wiki_page: WikiPage + ) -> str: + """Return URL to this view.""" + ws = team_member.workspace.uuid + return reverse("dashboard:wiki:edit", args=(ws, wiki_page.title)) + + def test_get( + self, + user_client: Client, + resource_url: str, + django_assert_num_queries: DjangoAssertNumQueries, + ) -> None: + """Test GETting the wiki page edit form.""" + with django_assert_num_queries(7): + assert user_client.get(resource_url).status_code == 200 + + def test_post_success( + self, user_client: Client, resource_url: str, wiki_page: WikiPage + ) -> None: + """Test successfully updating a wiki page.""" + d = {"content": "

Updated content

"} + assert user_client.post(resource_url, d).status_code == 302 + wiki_page.refresh_from_db() + assert "

Updated content

" in wiki_page.content + + def test_get_unrelated_user( + self, unrelated_user_client: Client, resource_url: str + ) -> None: + """Test that unrelated users can't edit wiki pages.""" + assert unrelated_user_client.get(resource_url).status_code == 404 + + def test_get_new_page( + self, user_client: Client, workspace: Workspace + ) -> None: + """Test GETting the edit view for a non-existent page shows create form.""" + url = reverse("dashboard:wiki:edit", args=(workspace.uuid, "n")) + assert user_client.get(url).status_code == 200 + + def test_post_create_new_page( + self, user_client: Client, workspace: Workspace + ) -> None: + """Test POSTing to the edit view for a non-existent page creates it.""" + initial_count = WikiPage.objects.count() + url = reverse("dashboard:wiki:edit", args=(workspace.uuid, "n")) + d = {"content": "

Hello world

"} + assert user_client.post(url, d).status_code == 302 + assert WikiPage.objects.count() == initial_count + 1 diff --git a/projectify/workspace/urls.py b/projectify/workspace/urls.py index bc2af8e72..d201de14a 100644 --- a/projectify/workspace/urls.py +++ b/projectify/workspace/urls.py @@ -34,6 +34,7 @@ wiki_index, wiki_page_edit, wiki_page_view, + wiki_recent_changes, ) from projectify.workspace.views.workspace import ( workspace_picture_view, @@ -174,6 +175,11 @@ ) wiki_patterns = ( path("/wiki", wiki_index, name="index"), + path( + "/wiki-recent-changes", + wiki_recent_changes, + name="recent-changes", + ), path("/wiki/", wiki_page_view, name="view"), path( "/wiki//edit", diff --git a/projectify/workspace/views/wiki.py b/projectify/workspace/views/wiki.py index 5cce99f33..150fec12c 100644 --- a/projectify/workspace/views/wiki.py +++ b/projectify/workspace/views/wiki.py @@ -9,6 +9,7 @@ from django import forms from django.http import Http404, HttpResponse from django.shortcuts import redirect, render +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_GET, require_http_methods @@ -20,6 +21,7 @@ from ..selectors.wiki import ( WikiPageDetailQuerySet, wiki_find_by_workspace_and_page_title, + wiki_find_recent_changes, ) from ..selectors.workspace import ( WorkspaceDetailQuerySet, @@ -30,6 +32,28 @@ logger = logging.getLogger(__name__) +@platform_view +@require_GET +def wiki_recent_changes( + request: AuthenticatedHttpRequest, ws_uuid: UUID +) -> HttpResponse: + """Show recently changed wiki pages.""" + ws = workspace_find_by_workspace_uuid( + who=request.user, workspace_uuid=ws_uuid, qs=WorkspaceDetailQuerySet + ) + if ws is None: + raise Http404(_("Workspace not found")) + pages = wiki_find_recent_changes(who=request.user, workspace=ws) + context = { + "workspace": ws, + "projects": ws.project_set.all(), + "pages": pages, + } + return render( + request, "workspace/wiki_recent_changes.html", context=context + ) + + @platform_view @require_GET def wiki_index( @@ -83,9 +107,41 @@ class Meta: @platform_view -@require_http_methods(["GET", "POST"]) +@require_GET def wiki_page_view( request: AuthenticatedHttpRequest, ws_uuid: UUID, page_title: str +) -> HttpResponse: + """Upload an image attachment to a workspace.""" + page = wiki_find_by_workspace_and_page_title( + ws_uuid=ws_uuid, + who=request.user, + title=page_title, + qs=WikiPageDetailQuerySet, + ) + if page is None: + ws = workspace_find_by_workspace_uuid( + who=request.user, + workspace_uuid=ws_uuid, + qs=WorkspaceDetailQuerySet, + ) + if ws is None: + raise Http404(_("Workspace not found")) + return redirect( + reverse("dashboard:wiki:edit", args=(ws_uuid, page_title)) + ) + context = { + "page": page, + "workspace": page.workspace, + # XXX slow + "projects": page.workspace.project_set.all(), + } + return render(request, "workspace/wiki_page_detail.html", context=context) + + +@platform_view +@require_http_methods(["GET", "POST"]) +def wiki_page_edit( + request: AuthenticatedHttpRequest, ws_uuid: UUID, page_title: str ) -> HttpResponse: """Upload an image attachment to a workspace.""" page = wiki_find_by_workspace_and_page_title( @@ -117,68 +173,35 @@ def wiki_page_view( status = 200 case _: raise RuntimeError("Shouldn't reach this") - context = { + context: dict[str, Any] = { "form": form, "workspace": ws, "projects": ws.project_set.all(), "page_title": page_title, } - template = "workspace/wiki_page_new.html" else: + ws = page.workspace match request.method: + case "POST": + form = WikiPageForm( + workspace=ws, instance=page, data=request.POST + ) + if form.is_valid(): + form.save() + return redirect(page) + else: + status = 400 case "GET": + form = WikiPageForm(workspace=page.workspace, instance=page) status = 200 case _: - status = 405 - # TODO show flash that the user can't POSt on an existing - # wiki page - context = { - "page": page, - "workspace": page.workspace, - # XXX slow - "projects": page.workspace.project_set.all(), - } - template = "workspace/wiki_page_detail.html" - return render(request, template, status=status, context=context) - - -@platform_view -@require_http_methods(["GET", "POST"]) -def wiki_page_edit( - request: AuthenticatedHttpRequest, ws_uuid: UUID, page_title: str -) -> HttpResponse: - """Upload an image attachment to a workspace.""" - page = wiki_find_by_workspace_and_page_title( - ws_uuid=ws_uuid, - who=request.user, - title=page_title, - qs=WikiPageDetailQuerySet, - ) - if page is None: - raise Http404( - _("Couldn't find wiki page {title}").format(title=page_title) - ) - match request.method: - case "POST": - form = WikiPageForm( - workspace=page.workspace, instance=page, data=request.POST - ) - if form.is_valid(): - form.save() - return redirect(page) - else: - status = 400 - case "GET": - form = WikiPageForm(workspace=page.workspace, instance=page) - status = 200 - case _: - raise RuntimeError("Shouldn't reach this") + raise RuntimeError("Shouldn't reach this") context = { "page": page, "form": form, - "workspace": page.workspace, + "workspace": ws, # XXX slow - "projects": page.workspace.project_set.all(), + "projects": ws.project_set.all(), } return render( request, From 68e2b866bf4e7d4fb5856d3bddd14fa948374e56 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 25 Jul 2026 15:39:49 +0900 Subject: [PATCH 39/41] Seeddb: Make wiki pages --- projectify/management/commands/seeddb.py | 34 +++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/projectify/management/commands/seeddb.py b/projectify/management/commands/seeddb.py index e177c2e4a..688784028 100644 --- a/projectify/management/commands/seeddb.py +++ b/projectify/management/commands/seeddb.py @@ -54,7 +54,13 @@ user_create_superuser, ) from projectify.workspace.const import TeamMemberRoles -from projectify.workspace.models import Project, Task, TeamMember, Workspace +from projectify.workspace.models import ( + Project, + Task, + TeamMember, + WikiPage, + Workspace, +) @dataclass @@ -94,6 +100,7 @@ class Command(BaseCommand): n_tasks: int n_add_users: int n_posts: int + n_wiki_pages: int seed_data: SeedData @@ -280,6 +287,23 @@ def create_corporate_accounts( customers = Customer.objects.bulk_create(customer_descs) self.stdout.write(f"Created customers for {len(customers)} workspaces") + def create_wiki_pages(self, workspaces: list[Workspace]) -> None: + """Create wiki pages for each workspace.""" + wiki_page_descs = [ + WikiPage( + workspace=workspace, + title=self.fake.unique.catch_phrase(), + content="".join( + f"

{self.fake.paragraph()}

" + for _ in range(randint(2, 6)) + ), + ) + for workspace in workspaces + for _ in range(self.n_wiki_pages) + ] + wiki_pages = WikiPage.objects.bulk_create(wiki_page_descs) + self.stdout.write(f"Created {len(wiki_pages)} wiki pages") + def create_blog_posts(self) -> None: """Create blog posts.""" existing_posts = Post.objects.count() @@ -356,6 +380,12 @@ def add_arguments(self, parser: ArgumentParser) -> None: default=40, help="Ensure N blog posts are present", ) + parser.add_argument( + "--n-wiki-pages", + type=int, + default=5, + help="Ensure N wiki pages are added to each new workspace", + ) def handle(self, *args: object, **options: Any) -> None: """Handle.""" @@ -371,6 +401,7 @@ def handle(self, *args: object, **options: Any) -> None: self.n_tasks = options["n_tasks"] self.n_add_users = options["n_add_users"] self.n_posts = options["n_posts"] + self.n_wiki_pages = options["n_wiki_pages"] if self.n_add_users > self.n_users: self.stdout.write( f"You are trying to add more users to each workspace " @@ -391,4 +422,5 @@ def handle(self, *args: object, **options: Any) -> None: self.create_corporate_accounts( seats=self.n_users, workspaces=workspaces ) + self.create_wiki_pages(workspaces) self.create_blog_posts() From 79cccc185ba8fa62bd30c53477ea95deed26d632 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 25 Jul 2026 15:40:10 +0900 Subject: [PATCH 40/41] Fix lint --- projectify/workspace/test/views/test_workspace.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/projectify/workspace/test/views/test_workspace.py b/projectify/workspace/test/views/test_workspace.py index fc134ac33..28c461244 100644 --- a/projectify/workspace/test/views/test_workspace.py +++ b/projectify/workspace/test/views/test_workspace.py @@ -182,7 +182,9 @@ class TestWorkspaceSettings: @pytest.fixture def resource_url(self, team_member: TeamMember) -> str: """Return URL to this view.""" - return reverse("dashboard:workspaces:settings", args=(team_member.workspace.uuid,)) + return reverse( + "dashboard:workspaces:settings", args=(team_member.workspace.uuid,) + ) def test_get_form( self, user_client: Client, resource_url: str, team_member: TeamMember From be97a9d3134e40d8f8765c2a1242d5b2915f0fe4 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 25 Jul 2026 15:40:59 +0900 Subject: [PATCH 41/41] Update tailwind styles --- projectify/static/css/dist/styles.css | 9 --------- 1 file changed, 9 deletions(-) diff --git a/projectify/static/css/dist/styles.css b/projectify/static/css/dist/styles.css index dc08a2c15..58c2aac06 100644 --- a/projectify/static/css/dist/styles.css +++ b/projectify/static/css/dist/styles.css @@ -1629,10 +1629,6 @@ video { align-items: center; } -.items-baseline { - align-items: baseline; -} - .justify-start { justify-content: flex-start; } @@ -2040,11 +2036,6 @@ video { line-height: 1.75rem; } -.text-sm { - font-size: 0.875rem; - line-height: 1.25rem; -} - .text-xl { font-size: 1.25rem; line-height: 1.75rem;