Skip to content

Commit ae5eb84

Browse files
committed
feat: add support for inviting from weblate.org
This allows to add users to newly added service from there.
1 parent a74071f commit ae5eb84

4 files changed

Lines changed: 316 additions & 16 deletions

File tree

wlhosted/integrations/forms.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,22 @@
2727
from django.utils import timezone
2828
from django.utils.translation import gettext_lazy as _
2929
from weblate.billing.models import Billing, Plan
30+
from weblate.trans.defines import FULLNAME_LENGTH
31+
from weblate.utils.validators import validate_fullname
3032

3133
from wlhosted.integrations.utils import get_origin
3234
from wlhosted.payments.models import Customer, Payment, date_format, get_period_delta
3335

3436

37+
class EnsureHostedUserForm(forms.Form):
38+
email = forms.EmailField(label=_("E-mail"))
39+
full_name = forms.CharField(
40+
label=_("Full name"),
41+
max_length=FULLNAME_LENGTH,
42+
validators=[validate_fullname],
43+
)
44+
45+
3546
class ChooseBillingForm(forms.Form):
3647
billing = forms.ModelChoiceField(
3748
queryset=Billing.objects.none(),

wlhosted/integrations/tests.py

Lines changed: 177 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from django.test.utils import override_settings
3131
from django.urls import reverse
3232
from django.utils import timezone
33+
from weblate.accounts.models import AuditLog
3334
from weblate.auth.models import User
3435
from weblate.billing.models import Billing, Invoice, Plan
3536
from weblate.trans.models import Project
@@ -44,6 +45,12 @@
4445
pending_payments,
4546
recurring_payments,
4647
)
48+
from wlhosted.integrations.views import (
49+
USER_ENSURE_RESPONSE_SALT,
50+
USER_ENSURE_SALT,
51+
USER_SYNC_RESPONSE_SALT,
52+
USER_SYNC_SALT,
53+
)
4754
from wlhosted.payments.backends import get_backend
4855
from wlhosted.payments.models import Customer, Payment
4956

@@ -197,7 +204,7 @@ def test_api_users(self) -> None:
197204
"payload": dumps(
198205
{"since": ""},
199206
key=TEST_PAYMENT_SECRET,
200-
salt="weblate.user-sync",
207+
salt=USER_SYNC_SALT,
201208
)
202209
},
203210
)
@@ -207,7 +214,7 @@ def test_api_users(self) -> None:
207214
payload = loads(
208215
response.json()["payload"],
209216
key=TEST_PAYMENT_SECRET,
210-
salt="weblate.user-sync-response",
217+
salt=USER_SYNC_RESPONSE_SALT,
211218
)
212219
self.assertIn(
213220
str(self.user.pk), {user["external_id"] for user in payload["users"]}
@@ -221,7 +228,7 @@ def test_api_users_requires_post(self) -> None:
221228
"payload": dumps(
222229
{"since": ""},
223230
key=TEST_PAYMENT_SECRET,
224-
salt="weblate.user-sync",
231+
salt=USER_SYNC_SALT,
225232
)
226233
},
227234
)
@@ -252,7 +259,7 @@ def test_api_users_incremental(self) -> None:
252259
"payload": dumps(
253260
{"since": since.isoformat()},
254261
key=TEST_PAYMENT_SECRET,
255-
salt="weblate.user-sync",
262+
salt=USER_SYNC_SALT,
256263
)
257264
},
258265
)
@@ -261,7 +268,7 @@ def test_api_users_incremental(self) -> None:
261268
payload = loads(
262269
response.json()["payload"],
263270
key=TEST_PAYMENT_SECRET,
264-
salt="weblate.user-sync-response",
271+
salt=USER_SYNC_RESPONSE_SALT,
265272
)
266273
self.assertEqual(
267274
[user["external_id"] for user in payload["users"]],
@@ -279,7 +286,7 @@ def test_api_users_invalid_cursor(self) -> None:
279286
"payload": dumps(
280287
{"since": since},
281288
key=TEST_PAYMENT_SECRET,
282-
salt="weblate.user-sync",
289+
salt=USER_SYNC_SALT,
283290
)
284291
},
285292
)
@@ -290,11 +297,174 @@ def test_api_users_invalid_cursor(self) -> None:
290297
def test_api_users_no_payment_secret(self) -> None:
291298
response = self.client.post(
292299
reverse("hosted-api-users"),
293-
{"payload": dumps({"since": ""}, key="", salt="weblate.user-sync")},
300+
{"payload": dumps({"since": ""}, key="", salt=USER_SYNC_SALT)},
294301
)
295302

296303
self.assertEqual(response.status_code, 400)
297304

305+
@override_settings(
306+
ENABLE_HTTPS=True,
307+
PAYMENT_SECRET=TEST_PAYMENT_SECRET,
308+
SITE_DOMAIN="hosted.example.org",
309+
)
310+
def test_api_user_ensure_creates_user(self) -> None:
311+
mail.outbox.clear()
312+
with patch("wlhosted.integrations.tasks.notify_user_change.delay"):
313+
response = self.client.post(
314+
reverse("hosted-api-user-ensure"),
315+
{
316+
"payload": dumps(
317+
{
318+
"email": "created@example.org",
319+
"full_name": "Created User",
320+
},
321+
key=TEST_PAYMENT_SECRET,
322+
salt=USER_ENSURE_SALT,
323+
)
324+
},
325+
)
326+
327+
self.assertEqual(response.status_code, 200)
328+
user = User.objects.get(email="created@example.org")
329+
self.assertEqual(user.username, "created")
330+
self.assertEqual(user.full_name, "Created User")
331+
self.assertFalse(user.has_usable_password())
332+
payload = loads(
333+
response.json()["payload"],
334+
key=TEST_PAYMENT_SECRET,
335+
salt=USER_ENSURE_RESPONSE_SALT,
336+
)
337+
self.assertTrue(payload["created"])
338+
self.assertEqual(payload["user"]["external_id"], str(user.pk))
339+
340+
audit = AuditLog.objects.get(user=user, activity="external-create")
341+
self.assertIn("created externally from weblate.org", audit.get_message())
342+
self.assertEqual(len(mail.outbox), 1)
343+
self.assertIn(
344+
"weblate.org created this Hosted Weblate sign-in account",
345+
mail.outbox[0].body,
346+
)
347+
self.assertIn("purchased Weblate services", mail.outbox[0].body)
348+
self.assertIn("Set account password", mail.outbox[0].body)
349+
self.assertIn("verification_code=", mail.outbox[0].body)
350+
self.assertIn(
351+
f"https://hosted.example.org{reverse('social:complete', args=('email',))}",
352+
mail.outbox[0].body,
353+
)
354+
355+
@override_settings(
356+
ENABLE_HTTPS=True,
357+
PAYMENT_SECRET=TEST_PAYMENT_SECRET,
358+
SITE_DOMAIN="hosted.example.org",
359+
)
360+
def test_api_user_ensure_uses_concurrently_created_user(self) -> None:
361+
def create_concurrent_user(email: str) -> str:
362+
User.objects.create_user(
363+
username="created",
364+
email=email,
365+
password=TESTPASSWORD,
366+
full_name="Created Elsewhere",
367+
)
368+
return "created-1"
369+
370+
mail.outbox.clear()
371+
with (
372+
patch("wlhosted.integrations.tasks.notify_user_change.delay"),
373+
patch(
374+
"wlhosted.integrations.views.make_unique_username",
375+
side_effect=create_concurrent_user,
376+
),
377+
):
378+
response = self.client.post(
379+
reverse("hosted-api-user-ensure"),
380+
{
381+
"payload": dumps(
382+
{
383+
"email": "created@example.org",
384+
"full_name": "Created User",
385+
},
386+
key=TEST_PAYMENT_SECRET,
387+
salt=USER_ENSURE_SALT,
388+
)
389+
},
390+
)
391+
392+
self.assertEqual(response.status_code, 200)
393+
user = User.objects.get(email="created@example.org")
394+
payload = loads(
395+
response.json()["payload"],
396+
key=TEST_PAYMENT_SECRET,
397+
salt=USER_ENSURE_RESPONSE_SALT,
398+
)
399+
self.assertFalse(payload["created"])
400+
self.assertEqual(payload["user"]["external_id"], str(user.pk))
401+
self.assertEqual(user.full_name, "Created Elsewhere")
402+
self.assertEqual(len(mail.outbox), 0)
403+
404+
@override_settings(
405+
PAYMENT_SECRET=TEST_PAYMENT_SECRET,
406+
SITE_DOMAIN="hosted.example.org",
407+
)
408+
def test_api_user_ensure_uses_existing_user(self) -> None:
409+
with patch("wlhosted.integrations.tasks.notify_user_change.delay"):
410+
existing = User.objects.create_user(
411+
username="existing",
412+
email="existing@example.org",
413+
password=TESTPASSWORD,
414+
full_name="Existing User",
415+
)
416+
mail.outbox.clear()
417+
418+
response = self.client.post(
419+
reverse("hosted-api-user-ensure"),
420+
{
421+
"payload": dumps(
422+
{
423+
"email": "existing@example.org",
424+
"full_name": "Changed User",
425+
},
426+
key=TEST_PAYMENT_SECRET,
427+
salt=USER_ENSURE_SALT,
428+
)
429+
},
430+
)
431+
432+
self.assertEqual(response.status_code, 200)
433+
existing.refresh_from_db()
434+
self.assertEqual(existing.full_name, "Existing User")
435+
payload = loads(
436+
response.json()["payload"],
437+
key=TEST_PAYMENT_SECRET,
438+
salt=USER_ENSURE_RESPONSE_SALT,
439+
)
440+
self.assertFalse(payload["created"])
441+
self.assertEqual(payload["user"]["external_id"], str(existing.pk))
442+
self.assertEqual(len(mail.outbox), 0)
443+
self.assertFalse(
444+
AuditLog.objects.filter(user=existing, activity="external-create").exists()
445+
)
446+
447+
@override_settings(PAYMENT_SECRET=TEST_PAYMENT_SECRET)
448+
def test_api_user_ensure_rejects_invalid_payload(self) -> None:
449+
response = self.client.post(
450+
reverse("hosted-api-user-ensure"),
451+
{
452+
"payload": dumps(
453+
{"email": "not-an-email", "full_name": "Invalid"},
454+
key=TEST_PAYMENT_SECRET,
455+
salt=USER_ENSURE_SALT,
456+
)
457+
},
458+
)
459+
460+
self.assertEqual(response.status_code, 400)
461+
462+
@override_settings(PAYMENT_SECRET="")
463+
def test_api_user_ensure_no_payment_secret(self) -> None:
464+
response = self.client.post(reverse("hosted-api-user-ensure"))
465+
466+
self.assertEqual(response.status_code, 400)
467+
298468
def test_pending_payments(self) -> None:
299469
self.test_create()
300470
Payment.objects.all().update(state=Payment.ACCEPTED)

wlhosted/integrations/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121

2222
from django.urls import path
2323

24-
from wlhosted.integrations.views import api_users
24+
from wlhosted.integrations.views import api_user_ensure, api_users
2525

2626
urlpatterns = [
2727
path("users/", api_users, name="hosted-api-users"),
28+
path("users/ensure/", api_user_ensure, name="hosted-api-user-ensure"),
2829
]

0 commit comments

Comments
 (0)