Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
0e5e2de
Introduce full_clean() on all models on save()
justuswilhelm Jul 2, 2026
2be6508
Blog: Use require_GET
justuswilhelm Jul 2, 2026
b31ec21
Workspace: Add attachment upload (WIP)
justuswilhelm Jul 2, 2026
663463b
Workspace: Fix quota query counts
justuswilhelm Jul 2, 2026
4d38ff6
Workspace: Add upload URL to project/task editor
justuswilhelm Jul 2, 2026
884289c
Workspace: Hide attachments behind feature flag
justuswilhelm Jul 2, 2026
856f1fe
Mark TODOs
justuswilhelm Jul 2, 2026
0a26270
Help: Update quota article
justuswilhelm Jul 2, 2026
18cbb11
Simplify base layouts
justuswilhelm Jul 2, 2026
8ced834
Storefront: Remove unused solutions_base.html
justuswilhelm Jul 2, 2026
0834c6d
Storefront: Simplify page layout
justuswilhelm Jul 2, 2026
265b027
User: Simplify base layouts
justuswilhelm Jul 2, 2026
fc4d016
Refactor use of storefront_base
justuswilhelm Jul 2, 2026
b1d0e22
Workspace: Make active_invites a list
justuswilhelm Jul 2, 2026
245293d
Workspace: Fix TODO
justuswilhelm Jul 2, 2026
c15e77b
Refactor UUID base models
justuswilhelm Jul 2, 2026
05222a3
Workspace: Simplify dashboard_base
justuswilhelm Jul 2, 2026
669e20e
Remove unused frontend_url ctx processor
justuswilhelm Jul 2, 2026
85bf9c8
Settings: Add new Wiki feature flags
justuswilhelm Jul 2, 2026
38d2415
Add new feature flag ctx processor
justuswilhelm Jul 2, 2026
669d04f
Workspace: Refactor attachment quota calc
justuswilhelm Jul 2, 2026
3e25efd
Workspace: Add wiki (WIP)
justuswilhelm Jul 2, 2026
163f4cb
Improve anchor tag href matching
justuswilhelm Jul 2, 2026
9e2e1ae
Refactor trix editor widget
justuswilhelm Jul 2, 2026
6bac42e
Bin: Fix djlint re-run
justuswilhelm Jul 2, 2026
bf2388c
Workspace: Continue Wiki Pages
justuswilhelm Jul 2, 2026
b5be582
Workspace: Removse unused code
justuswilhelm Jul 2, 2026
e6fd6c1
Workspace: Update query counts
justuswilhelm Jul 2, 2026
71483d0
Workspace: Fix wiki page form
justuswilhelm Jul 2, 2026
664f013
Refactor prose.js
justuswilhelm Jul 2, 2026
c494525
Clean empty <p><br></p> in rich text
justuswilhelm Jul 13, 2026
20f90a0
Docs: Describe how to use partialdef
justuswilhelm Jul 15, 2026
cf46dd0
Clean up dashboard_base.html
justuswilhelm Jul 15, 2026
1c88aca
Clean up task_detail.html
justuswilhelm Jul 17, 2026
3129afd
Clean up wiki page
justuswilhelm Jul 17, 2026
68ab64e
Fix some anchor consistency issues
justuswilhelm Jul 17, 2026
d182122
Improve workspace view tests
justuswilhelm Jul 22, 2026
efa5f44
Improve wiki
justuswilhelm Jul 22, 2026
68e2b86
Seeddb: Make wiki pages
justuswilhelm Jul 25, 2026
79cccc1
Fix lint
justuswilhelm Jul 25, 2026
be97a9d
Update tailwind styles
justuswilhelm Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion bin/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions docs/styleguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
<button>Hello {{ name }}</button>
{% endpartialdef %}

{% partial button %}
```

With `context={"name": "world"}`, this becomes the following:

```html
<button>Hello world</button>
```

Pass arguments with `{% with %}`:

```html
{% partialdef button %}
<button>Hello {{ name }}</button>
{% endpartialdef %}

{% with name="foobar" %}
{% partial button %}
{% endwith
```

This becomes the following:

```html
<button>Hello foobar</button>
```

## Template includes

### Submit button
Expand Down
4 changes: 1 addition & 3 deletions projectify/blog/templates/blog/blog_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
{% extends "base.html" %}
{% block body %}
<div class="flex grow flex-col">
{% block storefront_header %}
{% include "common/navigation/header/landing.html" %}
{% endblock storefront_header %}
{% include "common/navigation/header/landing.html" %}
<div class="mx-auto px-4 py-8 w-full container">
{% block blog_content %}
{% endblock blog_content %}
Expand Down
14 changes: 9 additions & 5 deletions projectify/blog/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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")
Expand All @@ -86,15 +90,15 @@ 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")
assert user_client.post(url, {"file": uploaded_file}).status_code == 403


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")
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion projectify/blog/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<str:name>
# 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)
Expand Down
26 changes: 25 additions & 1 deletion projectify/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -510,11 +522,23 @@ 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(),
)


@pytest.fixture
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
)
36 changes: 25 additions & 11 deletions projectify/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
2 changes: 1 addition & 1 deletion projectify/corporate/test/views/test_stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
42 changes: 23 additions & 19 deletions projectify/help/markdown_en/quota.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 1 addition & 1 deletion projectify/lib/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 25 additions & 2 deletions projectify/lib/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand Down Expand Up @@ -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."""
Expand All @@ -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 <paris@withlogic.co>
Expand Down
Loading