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 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 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/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) diff --git a/projectify/conftest.py b/projectify/conftest.py index c00ed9453..1db6bc2f4 100644 --- a/projectify/conftest.py +++ b/projectify/conftest.py @@ -54,16 +54,19 @@ user_invite_redeem, ) from projectify.workspace.models import ( + Attachment, Project, Task, TeamMember, TeamMemberInvite, TeamMemberRoles, + WikiPage, Workspace, ) 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, @@ -72,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, @@ -463,6 +467,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.""" @@ -510,7 +522,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(), ) @@ -518,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/context_processors.py b/projectify/context_processors.py index 620bc71dc..d0b66d2c5 100644 --- a/projectify/context_processors.py +++ b/projectify/context_processors.py @@ -3,22 +3,36 @@ # SPDX-FileCopyrightText: 2021, 2023 JWP Consulting GK """Projectify context processors.""" +from dataclasses import asdict from typing import Mapping -from django.conf import settings from django.http import HttpRequest +from django.urls.resolvers import ResolverMatch - -def frontend_url(request: object) -> Mapping[str, str]: - """Add FRONTEND_URL to context.""" - return {"FRONTEND_URL": settings.FRONTEND_URL} +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/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/help/markdown_en/quota.md b/projectify/help/markdown_en/quota.md index a071e1379..8f42770d2 100644 --- a/projectify/help/markdown_en/quota.md +++ b/projectify/help/markdown_en/quota.md @@ -4,43 +4,47 @@ 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 +- Wiki pages - 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 25 wiki pages - 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). 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/lib/models.py b/projectify/lib/models.py index d28f447cd..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 _ @@ -140,7 +147,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.""" @@ -149,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/lib/utils.py b/projectify/lib/utils.py index 328a2919e..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,14 +25,28 @@ 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 " " -> " " # 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/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() 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/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/rules.py b/projectify/rules.py index 6c0bb8fd1..2bb2d609a 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 @@ -112,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", @@ -160,6 +169,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/settings/base.py b/projectify/settings/base.py index 535fe3730..d43303fed 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? @@ -348,9 +356,10 @@ 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", + # 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 diff --git a/projectify/settings/development.py b/projectify/settings/development.py index 2bf3c1c60..690643a3f 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,10 @@ def add_dev_middleware( class Development(Base): """Development configuration.""" + FEATURE_FLAGS = FeatureFlags( + workspace_attachments=True, workspace_wikis=True + ) + SITE_TITLE = "Local Development" SECRET_KEY = "development" diff --git a/projectify/settings/test.py b/projectify/settings/test.py index f63804530..2a49ec6fe 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,10 @@ class Test(Base): """Test configuration.""" + FEATURE_FLAGS = FeatureFlags( + workspace_attachments=True, workspace_wikis=True + ) + SITE_TITLE = "Projectify Pytest" MIDDLEWARE = [ diff --git a/projectify/settings/types.py b/projectify/settings/types.py index 40ace3261..6ae4e1557 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.""" @@ -58,6 +60,20 @@ 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 = False + """Set to True to Enable workspace Wikis.""" + workspace_wikis: bool = False diff --git a/projectify/static/css/dist/styles.css b/projectify/static/css/dist/styles.css index b9c4fceef..58c2aac06 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; } @@ -1553,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)); @@ -1852,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; @@ -1933,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; @@ -1992,10 +1970,6 @@ video { padding-bottom: 5rem; } -.pb-4 { - padding-bottom: 1rem; -} - .pb-8 { padding-bottom: 2rem; } @@ -2351,10 +2325,6 @@ video { } @media (min-width: 640px) { - .sm\:order-2 { - order: 2; - } - .sm\:grid { display: grid; } @@ -2434,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)); } @@ -2446,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; } @@ -2549,10 +2519,6 @@ video { max-width: 42rem; } - .lg\:max-w-xs { - max-width: 20rem; - } - .lg\:grid-cols-\[1fr_max-content\] { grid-template-columns: 1fr max-content; } @@ -2605,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/static/prose/prose.js b/projectify/static/prose/prose.js index b36062676..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,23 +221,26 @@ 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( - "Project", - "Suggest projects", - "x-suggest-project", + const { suggestLinksUrl, suggestProjectsUrl } = editor.dataset; + buttonGroup.appendChild( + createActionButton("Project", "Suggest projects", "x-suggest-project"), ); - buttonGroup.appendChild(suggestProjectsButton); initializeLinkSuggestions(editor, suggestProjectsUrl, "project"); - - const suggestTasksButton = createActionButton( - "Task", - "Suggest tasks", - "x-suggest-task", + buttonGroup.appendChild( + createActionButton("Task", "Suggest tasks", "x-suggest-task"), ); - buttonGroup.appendChild(suggestTasksButton); 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/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 f761fd980..000000000 Binary files a/projectify/storefront/static/hero-accessibility.png and /dev/null differ 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 970feb237..000000000 Binary files a/projectify/storefront/static/hero-accessibility.webp and /dev/null differ 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 %} diff --git a/projectify/storefront/templates/storefront/accessibility.html b/projectify/storefront/templates/storefront/accessibility.html index b2329422d..fc07f53ff 100644 --- a/projectify/storefront/templates/storefront/accessibility.html +++ b/projectify/storefront/templates/storefront/accessibility.html @@ -1,20 +1,11 @@ {# 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 "Accessibility statement - Projectify" %} {% endblock title %} -{% block storefront_content %} -
-
-
-

{% 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/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/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/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/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/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 %} diff --git a/projectify/templates/dashboard_base.html b/projectify/templates/dashboard_base.html index 1d28c0d32..8713cfcb5 100644 --- a/projectify/templates/dashboard_base.html +++ b/projectify/templates/dashboard_base.html @@ -3,42 +3,69 @@ {% 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/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/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/templatetags/projectify.py b/projectify/templatetags/projectify.py index 8d7670386..2d222906b 100644 --- a/projectify/templatetags/projectify.py +++ b/projectify/templatetags/projectify.py @@ -54,15 +54,17 @@ 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(): + case str(), args, kwargs if len(args) == len(kwargs) == 0: try: - url = reverse(href, args=args, kwargs=kwargs) + url = reverse(href) except NoReverseMatch: url = href - case model: + 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 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/user/templates/socialaccount/base_entrance.html b/projectify/user/templates/socialaccount/base_entrance.html index 5d4944a77..c8e05993a 100644 --- a/projectify/user/templates/socialaccount/base_entrance.html +++ b/projectify/user/templates/socialaccount/base_entrance.html @@ -1,19 +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 storefront_header %} - {% include "common/navigation/header/landing.html" %} -{% endblock storefront_header %} {% 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 b340824c0..c59fad738 100644 --- a/projectify/user/templates/socialaccount/base_manage.html +++ b/projectify/user/templates/socialaccount/base_manage.html @@ -1,20 +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 storefront_header %} - {% include "common/navigation/header/landing.html" %} -{% endblock storefront_header %} {% 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 %} 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 89f20f4bc..4b9076199 100644 --- a/projectify/user/templates/user_profile_base.html +++ b/projectify/user/templates/user_profile_base.html @@ -1,20 +1,16 @@ {# SPDX-FileCopyrightText: 2025-2026 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "storefront_base.html" %} +{% extends "user_base.html" %} {% load i18n %} -{% block storefront_header %} +{% block user_header %} {% include "common/navigation/header/dashboard.html" %} -{% endblock storefront_header %} -{% block storefront_content %} -
    -
    -
    -

    {% 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 %}
    -{% endblock storefront_content %} +{% endblock user_content %} 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/forms.py b/projectify/workspace/forms.py index 2a9a5e636..2d47e9d8d 100644 --- a/projectify/workspace/forms.py +++ b/projectify/workspace/forms.py @@ -9,9 +9,51 @@ 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.workspace.models import TeamMember +from projectify.lib.forms import RichTextEditor, SafeImageField +from projectify.lib.settings import get_settings + +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,), + ), + } + 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( + "dashboard:attachments:create", args=(workspace.uuid,) + ), + attrs=attrs, + ) @dataclass @@ -120,3 +162,15 @@ 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) + 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/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 d5d2a0d35..3a2270509 100644 --- a/projectify/workspace/models.py +++ b/projectify/workspace/models.py @@ -4,7 +4,7 @@ """Workspace models.""" import logging -import uuid +from pathlib import Path from typing import TYPE_CHECKING, Any, Optional from django.conf import settings @@ -16,6 +16,7 @@ from projectify.lib.models import ( BaseModel, + BaseModelUUID, RichTextField, TitleDescriptionModel, ) @@ -35,7 +36,7 @@ logger = logging.getLogger(__name__) -class Workspace(TitleDescriptionModel, BaseModel): +class Workspace(TitleDescriptionModel, BaseModelUUID): """Workspace.""" users = models.ManyToManyField( @@ -43,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 ) @@ -66,11 +66,13 @@ 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"] + attachment_set: RelatedManager["Attachment"] teammemberinvite_set: RelatedManager["TeamMemberInvite"] - active_invites: Optional[RelatedManager["TeamMemberInvite"]] + active_invites: Optional[list["TeamMemberInvite"]] def __str__(self) -> str: """Return title.""" @@ -103,7 +105,35 @@ class Meta: ) -class Project(TitleDescriptionModel, BaseModel): +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.""" workspace = models.ForeignKey["Workspace"]( @@ -115,7 +145,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, @@ -145,7 +174,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 @@ -159,7 +188,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, @@ -231,7 +259,7 @@ class Meta: ordering = ("created",) -class TeamMember(BaseModel): +class TeamMember(BaseModelUUID): """Workspace to user mapping.""" workspace = models.ForeignKey["Workspace"]( @@ -249,7 +277,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, @@ -284,9 +311,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..8a9c7df57 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,9 @@ from ..models import Task, Workspace -Resource = Literal["Task", "Project", "TeamMemberAndInvite"] +Resource = Literal[ + "Task", "Project", "TeamMemberAndInvite", "Attachment", "WikiPage" +] Limitation = Union[None, int] @@ -32,22 +36,27 @@ class Limitations(TypedDict): Task: Limitation Project: Limitation TeamMemberAndInvite: Limitation + Attachment: Limitation + WikiPage: Limitation trial_conditions: Limitations = { "Task": 1000, "Project": 10, "TeamMemberAndInvite": 2, + # No attachments for trial + "Attachment": 0, + "WikiPage": 25, } + # 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: @@ -58,31 +67,19 @@ 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] - if resource == "TeamMemberAndInvite": - customer = workspace.customer - return customer.seats - return None - - -def get_workspace_resource_count( - resource: Resource, workspace: Workspace -) -> int: - """Return resource count for a specific resource.""" + match customer_check_active_for_workspace(workspace=workspace): + case "trial" | "inactive": + return trial_conditions[resource] + case "full": + pass match resource: - case "Task": - return Task.objects.filter(project__workspace=workspace).count() - case "Project": - return 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 + customer = workspace.customer + return customer.seats + case "Attachment": + return 100 * 1024 * 1024 + case _: + return None def workspace_quota_for(*, resource: Resource, workspace: Workspace) -> Quota: @@ -91,7 +88,28 @@ def workspace_quota_for(*, resource: Resource, workspace: Workspace) -> Quota: # 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) + match resource: + case "WikiPage": + current = workspace.wikipage_set.count() + case "Task": + current = Task.objects.filter(project__workspace=workspace).count() + case "Project": + current = workspace.project_set.count() + case "TeamMemberAndInvite": + user_count = workspace.users.count() + invite_count = workspace.teammemberinvite_set.filter( + redeemed=False + ).count() + current = user_count + invite_count + case "Attachment": + aggregate = workspace.attachment_set.aggregate( + total_size=Sum("size", default=0) + ) + match aggregate: + case {"total_size": int() as result}: + current = result + case other: + raise RuntimeError(f"Unexpected result {other}") return Quota(current=current, limit=limit, can_create_more=current < limit) @@ -102,7 +120,9 @@ 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"), + 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/selectors/wiki.py b/projectify/workspace/selectors/wiki.py new file mode 100644 index 000000000..5a3a79d84 --- /dev/null +++ b/projectify/workspace/selectors/wiki.py @@ -0,0 +1,42 @@ +# 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 django.db.models import QuerySet + +from projectify.user.models import User + +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, + ws_uuid: UUID, + title: str, + qs: QuerySet[WikiPage] | None = None, +) -> Optional[WikiPage]: + """Find wiki page by title and for user workspace.""" + 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/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/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) 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/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 %}
    {% 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 %} 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 %}
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..2317473e5 --- /dev/null +++ b/projectify/workspace/templates/workspace/wiki_page_detail.html @@ -0,0 +1,19 @@ +{# 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 }}

+ {% 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 new file mode 100644 index 000000000..ed6adcce5 --- /dev/null +++ b/projectify/workspace/templates/workspace/wiki_page_new.html @@ -0,0 +1,20 @@ +{# 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 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 %} + {{ 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 new file mode 100644 index 000000000..13b06a142 --- /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 "workspace/form_base.html" %} +{% load projectify %} +{% load rules %} +{% load i18n %} +{% block extrahead %} + + {{ form.media }} +{% endblock extrahead %} +{% block title %} + {% blocktrans with title=page.title %}Editing {{ title }} - Projectify{% endblocktrans %} +{% 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 %} + {{ form.as_p }} +{% endblock form_content %} 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/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/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/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_attachment.py b/projectify/workspace/test/views/test_attachment.py new file mode 100644 index 000000000..9e78170b8 --- /dev/null +++ b/projectify/workspace/test/views/test_attachment.py @@ -0,0 +1,86 @@ +# 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 +# TODO test upload file size limits +# TODO test file type validation diff --git a/projectify/workspace/test/views/test_project.py b/projectify/workspace/test/views/test_project.py index 48ba98f3c..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(15): + with django_assert_num_queries(21): 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"} @@ -111,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(22): + # 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(22): + with django_assert_num_queries(31): 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_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/test/views/test_workspace.py b/projectify/workspace/test/views/test_workspace.py index edced5605..28c461244 100644 --- a/projectify/workspace/test/views/test_workspace.py +++ b/projectify/workspace/test/views/test_workspace.py @@ -148,53 +148,49 @@ 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 +200,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 @@ -219,7 +215,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, { @@ -351,8 +347,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 @@ -438,16 +433,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) @@ -457,7 +452,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: @@ -466,7 +460,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"}, @@ -482,21 +476,21 @@ 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.""" # 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 @@ -517,8 +511,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 -> 13 - with django_assert_num_queries(13): + with django_assert_num_queries(15): response = user_client.get(resource_url) assert response.status_code == 200 # These quotas should be listed @@ -567,7 +560,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(18): response = user_client.post(resource_url, data=data) assert response.status_code == 302 assert response.headers["Location"] == "https://www.example.com" @@ -614,7 +607,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(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" @@ -669,8 +662,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 -> 13 - with django_assert_num_queries(13): + 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 @@ -685,8 +677,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 @@ -706,19 +697,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 - # Gone down from 18 -> 17 - with django_assert_num_queries(17): + 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() @@ -730,18 +719,17 @@ def test_redeeming_valid_code( self, user_client: Client, resource_url: str, - team_member: TeamMember, coupon: Coupon, django_assert_num_queries: DjangoAssertNumQueries, - workspace: Workspace, unpaid_customer: Customer, ) -> None: """Test that workspace subscription is activated correctly.""" + workspace = unpaid_customer.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(17): + with django_assert_num_queries(24): response = user_client.post(resource_url, data=data) assert response.status_code == 302 diff --git a/projectify/workspace/types.py b/projectify/workspace/types.py index 93b6b1dbc..4769754c4 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 @@ -25,9 +25,8 @@ class WorkspaceQuota: """Contain all workspace quota values.""" workspace_status: WorkspaceFeatures + wiki_pages: Quota 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..d201de14a 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 @@ -10,6 +9,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 ( @@ -27,6 +30,12 @@ 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, + wiki_recent_changes, +) from projectify.workspace.views.workspace import ( workspace_picture_view, workspace_search_view, @@ -43,6 +52,9 @@ logger = logging.getLogger(__name__) + +settings = get_settings() + # TODO rename to workspace # app_name = "workspace" app_name = "dashboard" @@ -99,6 +111,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. " @@ -145,7 +167,27 @@ "/picture", team_member_picture, name="picture" ), ) -urlpatterns = ( +attachment_patterns = ( + path("/attachments", attachment_create_view, name="create"), + path( + "/attachments/", attachment_view, name="view" + ), +) +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", + wiki_page_edit, + name="edit", + ), +) +urlpatterns: UrlPatterns = ( path("", redirect_to_dashboard, name="dashboard"), # Avatar path( @@ -153,12 +195,18 @@ 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"))), ) +if settings.FEATURE_FLAGS.workspace_attachments: + urlpatterns = ( + *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/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/project.py b/projectify/workspace/views/project.py index b5a1f436d..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 @@ -209,29 +208,12 @@ 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,), - ) + 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 991ae73ae..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 +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 @@ -80,17 +78,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 +116,7 @@ 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,), - ) + self.fields["description"].widget = WorkspaceRichTextEditor(workspace) if focus_field is None: pass @@ -287,7 +265,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: diff --git a/projectify/workspace/views/wiki.py b/projectify/workspace/views/wiki.py new file mode 100644 index 000000000..150fec12c --- /dev/null +++ b/projectify/workspace/views/wiki.py @@ -0,0 +1,211 @@ +# 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.types import AuthenticatedHttpRequest +from projectify.lib.views import platform_view +from projectify.workspace.forms import WorkspaceRichTextEditor + +from ..models import WikiPage, Workspace +from ..selectors.wiki import ( + WikiPageDetailQuerySet, + wiki_find_by_workspace_and_page_title, + wiki_find_recent_changes, +) +from ..selectors.workspace import ( + WorkspaceDetailQuerySet, + 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_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( + 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, 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, + "projects": page.workspace.project_set.all(), + } + 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.""" + 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) + + 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 = ("content",) + + +@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, + 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( + 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, page_title=page_title, data=request.POST + ) + if form.is_valid(): + page = form.save() + return redirect(page) + else: + status = 400 + case "GET": + form = WikiPageForm(workspace=ws, page_title=page_title) + status = 200 + case _: + raise RuntimeError("Shouldn't reach this") + context: dict[str, Any] = { + "form": form, + "workspace": ws, + "projects": ws.project_set.all(), + "page_title": page_title, + } + 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 _: + raise RuntimeError("Shouldn't reach this") + context = { + "page": page, + "form": form, + "workspace": ws, + # XXX slow + "projects": ws.project_set.all(), + } + return render( + request, + "workspace/wiki_page_update.html", + status=status, + context=context, + ) diff --git a/projectify/workspace/views/workspace.py b/projectify/workspace/views/workspace.py index 339414c58..d63633b07 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( @@ -785,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)