Skip to content

Commit 9a51f6f

Browse files
committed
fix(api): allow project creating in default user workspace
Restore the functionality present before introudction of workspaces. The user without a project.add permission and access to a single workspace will create the project within that workspace. Fixes #20147
1 parent a9a76d3 commit 9a51f6f

4 files changed

Lines changed: 188 additions & 7 deletions

File tree

docs/api.rst

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -915,9 +915,10 @@ Projects
915915
:type web: string
916916
:param workspace: Optional workspace UUID. Creating a project in a workspace
917917
requires :guilabel:`Add projects to workspace` permission
918-
for that workspace. Creating a project without a
919-
workspace requires the site-wide :guilabel:`Add new
920-
projects` permission. See
918+
for that workspace. When omitted, Weblate can use the
919+
only eligible workspace. Creating a project without an
920+
eligible workspace requires the site-wide
921+
:guilabel:`Add new projects` permission. See
921922
:ref:`workspace-project-creation`.
922923
:type workspace: string
923924

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Weblate 2026.7
5252
* :ref:`auto-translation` no longer validates hidden component fields when using machine translation.
5353
* :guilabel:`Strings marked for edit` links now include all strings needing editing, checking, or rewriting.
5454
* Anonymous permission checks no longer fail when loading teams scoped to projects or workspaces.
55+
* API project creation can again use the user's only eligible workspace when no explicit workspace is supplied.
5556

5657
.. rubric:: Compatibility
5758

weblate/api/tests.py

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3104,6 +3104,149 @@ def test_create_with_invalid_workspace_uuid(self) -> None:
31043104
},
31053105
)
31063106

3107+
def test_create_with_project_add_permission(self) -> None:
3108+
self.grant_perm_to_user("project.add")
3109+
3110+
response = self.do_request(
3111+
"api:project-list",
3112+
method="post",
3113+
code=201,
3114+
request={
3115+
"name": "API project",
3116+
"slug": "api-project",
3117+
"web": "https://weblate.org/",
3118+
},
3119+
)
3120+
project = Project.objects.get(pk=response.data["id"])
3121+
self.assertIsNone(project.workspace)
3122+
3123+
def test_create_with_workspace_permission(self) -> None:
3124+
with modify_settings(INSTALLED_APPS={"prepend": "weblate.billing"}):
3125+
workspace = Workspace.objects.create(name="API workspace")
3126+
workspace.add_owner(self.user)
3127+
3128+
response = self.do_request(
3129+
"api:project-list",
3130+
method="post",
3131+
code=201,
3132+
request={
3133+
"name": "API project",
3134+
"slug": "api-project",
3135+
"web": "https://weblate.org/",
3136+
"workspace": str(workspace.pk),
3137+
},
3138+
)
3139+
project = Project.objects.get(pk=response.data["id"])
3140+
self.assertEqual(project.workspace_id, workspace.pk)
3141+
3142+
def test_create_with_workspace_permission_denied(self) -> None:
3143+
workspace = Workspace.objects.create(name="API workspace")
3144+
3145+
response = self.do_request(
3146+
"api:project-list",
3147+
method="post",
3148+
code=403,
3149+
request={
3150+
"name": "API project",
3151+
"slug": "api-project",
3152+
"web": "https://weblate.org/",
3153+
"workspace": str(workspace.pk),
3154+
},
3155+
)
3156+
self.assertEqual(
3157+
{
3158+
"errors": [
3159+
{
3160+
"attr": None,
3161+
"code": "permission_denied",
3162+
"detail": "Can not create projects",
3163+
}
3164+
],
3165+
"type": "client_error",
3166+
},
3167+
response.data,
3168+
)
3169+
3170+
def test_create_with_invalid_billing_workspace(self) -> None:
3171+
with modify_settings(INSTALLED_APPS={"prepend": "weblate.billing"}):
3172+
billing = create_test_billing(self.user, invoice=False)
3173+
billing.in_limits = False
3174+
billing.save(update_fields=["in_limits"])
3175+
3176+
response = self.do_request(
3177+
"api:project-list",
3178+
method="post",
3179+
code=403,
3180+
request={
3181+
"name": "API project",
3182+
"slug": "api-project",
3183+
"web": "https://weblate.org/",
3184+
"workspace": str(billing.workspace_id),
3185+
},
3186+
)
3187+
self.assertEqual(
3188+
{
3189+
"errors": [
3190+
{
3191+
"attr": None,
3192+
"code": "permission_denied",
3193+
"detail": "No valid billing found or limit exceeded.",
3194+
}
3195+
],
3196+
"type": "client_error",
3197+
},
3198+
response.data,
3199+
)
3200+
3201+
def test_create_with_single_workspace(self) -> None:
3202+
with modify_settings(INSTALLED_APPS={"remove": "weblate.billing"}):
3203+
workspace = Workspace.objects.create(name="API workspace")
3204+
workspace.add_owner(self.user)
3205+
3206+
response = self.do_request(
3207+
"api:project-list",
3208+
method="post",
3209+
code=201,
3210+
request={
3211+
"name": "API project",
3212+
"slug": "api-project",
3213+
"web": "https://weblate.org/",
3214+
},
3215+
)
3216+
project = Project.objects.get(pk=response.data["id"])
3217+
self.assertEqual(project.workspace_id, workspace.pk)
3218+
3219+
def test_create_with_multiple_workspaces_requires_workspace(self) -> None:
3220+
with modify_settings(INSTALLED_APPS={"remove": "weblate.billing"}):
3221+
first_workspace = Workspace.objects.create(name="First API workspace")
3222+
first_workspace.add_owner(self.user)
3223+
second_workspace = Workspace.objects.create(name="Second API workspace")
3224+
second_workspace.add_owner(self.user)
3225+
3226+
response = self.do_request(
3227+
"api:project-list",
3228+
method="post",
3229+
code=400,
3230+
request={
3231+
"name": "API project",
3232+
"slug": "api-project",
3233+
"web": "https://weblate.org/",
3234+
},
3235+
)
3236+
self.assertEqual(
3237+
{
3238+
"errors": [
3239+
{
3240+
"attr": "workspace",
3241+
"code": "invalid",
3242+
"detail": "Specify a workspace when multiple workspaces can be used.",
3243+
}
3244+
],
3245+
"type": "validation_error",
3246+
},
3247+
response.data,
3248+
)
3249+
31073250
def test_create_with_billing(self) -> None:
31083251
with modify_settings(INSTALLED_APPS={"remove": "weblate.billing"}):
31093252
response = self.do_request(
@@ -3165,11 +3308,11 @@ def test_create_with_billing(self) -> None:
31653308
"name": "API project",
31663309
"slug": "api-project",
31673310
"web": "https://weblate.org/",
3168-
"workspace": str(billing.workspace_id),
31693311
},
31703312
)
31713313
project = Project.objects.get(pk=response.data["id"])
31723314
self.assertEqual(project.billing, billing)
3315+
self.assertEqual(project.workspace_id, billing.workspace_id)
31733316

31743317
response = self.do_request(
31753318
"api:project-list",
@@ -3179,7 +3322,6 @@ def test_create_with_billing(self) -> None:
31793322
"name": "API project 2",
31803323
"slug": "api-project-2",
31813324
"web": "https://weblate.org/",
3182-
"workspace": str(billing.workspace_id),
31833325
},
31843326
)
31853327
self.assertEqual(

weblate/api/views.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,6 +1570,20 @@ class ProjectViewSet(
15701570
lookup_field = "slug"
15711571
request: Request # type: ignore[assignment]
15721572

1573+
def get_create_workspaces(self, request: Request):
1574+
workspaces = request.user.workspaces_with_perm("workspace.add_project")
1575+
if "weblate.billing" in settings.INSTALLED_APPS:
1576+
# ruff: ignore[import-outside-top-level]
1577+
from weblate.billing.models import Billing
1578+
1579+
valid_billing_workspaces = Billing.objects.for_user_within_limits(
1580+
request.user
1581+
).values("workspace")
1582+
workspaces = workspaces.filter(
1583+
Q(billing__isnull=True) | Q(pk__in=valid_billing_workspaces)
1584+
)
1585+
return workspaces
1586+
15731587
def get_queryset(self):
15741588
return self.request.user.allowed_projects.prefetch_related(
15751589
"addon_set"
@@ -1801,6 +1815,7 @@ def addons(self, request: Request, **kwargs):
18011815
def create(self, request: Request, *args, **kwargs):
18021816
"""Create a new project."""
18031817
workspace_id = request.data.get("workspace")
1818+
data = request.data
18041819
if workspace_id:
18051820
try:
18061821
workspace = get_object_or_404(Workspace, pk=workspace_id)
@@ -1823,9 +1838,31 @@ def create(self, request: Request, *args, **kwargs):
18231838
request, "No valid billing found or limit exceeded."
18241839
)
18251840
elif not request.user.has_perm("project.add"):
1826-
self.permission_denied(request, "Can not create projects")
1841+
if not request.user.workspaces_with_perm("workspace.add_project").exists():
1842+
self.permission_denied(request, "Can not create projects")
1843+
1844+
try:
1845+
workspace = self.get_create_workspaces(request).get()
1846+
except Workspace.DoesNotExist:
1847+
self.permission_denied(
1848+
request, "No valid billing found or limit exceeded."
1849+
)
1850+
except Workspace.MultipleObjectsReturned as error:
1851+
raise ValidationError(
1852+
{
1853+
"workspace": gettext(
1854+
"Specify a workspace when multiple workspaces can be used."
1855+
)
1856+
}
1857+
) from error
1858+
data = request.data.copy()
1859+
data["workspace"] = str(workspace.pk)
18271860
self.request = request
1828-
return super().create(request, *args, **kwargs)
1861+
serializer = self.get_serializer(data=data)
1862+
serializer.is_valid(raise_exception=True)
1863+
self.perform_create(serializer)
1864+
headers = self.get_success_headers(serializer.data)
1865+
return Response(serializer.data, status=HTTP_201_CREATED, headers=headers)
18291866

18301867
def perform_create(self, serializer) -> None:
18311868
with transaction.atomic():

0 commit comments

Comments
 (0)