Skip to content

Commit 24ef1c9

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 24ef1c9

7 files changed

Lines changed: 350 additions & 23 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/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ def queue_user_sync(user: User, changes: dict[str, object] | None = None) -> Non
172172
UserSyncState.objects.update_or_create(
173173
user=user, defaults={"updated": timezone.now()}
174174
)
175-
notify_user_change.delay(get_user_sync_payload(user, changes))
175+
payload = get_user_sync_payload(user, changes)
176+
transaction.on_commit(lambda: notify_user_change.delay(payload))
176177

177178

178179
@receiver(pre_save, sender=User)

wlhosted/integrations/templates/hosted/create.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ <h3 class="card-title">{% translate "Current billing status" %}</h3>
3131
</div>
3232
{% endif %}
3333
{% if billing %}
34-
{% include "billing/status.html" with projects=billing.projects.all hide_buttons=True %}
34+
{% include "billing/status.html" with projects=billing.all_projects hide_buttons=True %}
3535
{% endif %}
3636
</div>
3737
{% endif %}

wlhosted/integrations/templates/mail/billing_paid.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<p>{% translate "This billing plan covers following projects:" %}</p>
99

1010
<ul>
11-
{% for project in billing.projects.all %}
11+
{% for project in billing.all_projects %}
1212
<li>
1313
<a href="{{ project.get_absolute_url }}">{{ project }}</a>
1414
</li>

wlhosted/integrations/tests.py

Lines changed: 202 additions & 10 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,13 @@
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+
make_unique_username,
54+
)
4755
from wlhosted.payments.backends import get_backend
4856
from wlhosted.payments.models import Customer, Payment
4957

@@ -97,7 +105,7 @@ def create_trial(self):
97105
bill = Billing.objects.create(state=Billing.STATE_TRIAL, plan=self.plan_b)
98106
bill.owners.add(self.user)
99107
project = Project.objects.create(name="Project", slug="project")
100-
bill.projects.add(project)
108+
bill.add_project(project)
101109
project.add_user(self.user)
102110
return bill
103111

@@ -152,13 +160,28 @@ def test_queue_user_sync_refreshes_cursor(self) -> None:
152160
old_updated = timezone.now() - relativedelta(hours=1)
153161
UserSyncState.objects.filter(pk=sync_state.pk).update(updated=old_updated)
154162

155-
with patch("wlhosted.integrations.tasks.notify_user_change.delay") as delay:
163+
with (
164+
patch("wlhosted.integrations.tasks.notify_user_change.delay") as delay,
165+
self.captureOnCommitCallbacks(execute=True),
166+
):
156167
queue_user_sync(self.user)
157168

158169
sync_state.refresh_from_db()
159170
self.assertGreater(sync_state.updated, old_updated)
160171
delay.assert_called_once()
161172

173+
@override_settings(PAYMENT_SECRET=TEST_PAYMENT_SECRET)
174+
def test_queue_user_sync_notifies_after_commit(self) -> None:
175+
with (
176+
patch("wlhosted.integrations.tasks.notify_user_change.delay") as delay,
177+
self.captureOnCommitCallbacks(execute=False) as callbacks,
178+
):
179+
queue_user_sync(self.user)
180+
delay.assert_not_called()
181+
182+
self.assertEqual(len(callbacks), 1)
183+
delay.assert_not_called()
184+
162185
@override_settings(PAYMENT_SECRET="")
163186
def test_queue_user_sync_no_payment_secret(self) -> None:
164187
UserSyncState.objects.filter(user=self.user).delete()
@@ -197,7 +220,7 @@ def test_api_users(self) -> None:
197220
"payload": dumps(
198221
{"since": ""},
199222
key=TEST_PAYMENT_SECRET,
200-
salt="weblate.user-sync",
223+
salt=USER_SYNC_SALT,
201224
)
202225
},
203226
)
@@ -207,7 +230,7 @@ def test_api_users(self) -> None:
207230
payload = loads(
208231
response.json()["payload"],
209232
key=TEST_PAYMENT_SECRET,
210-
salt="weblate.user-sync-response",
233+
salt=USER_SYNC_RESPONSE_SALT,
211234
)
212235
self.assertIn(
213236
str(self.user.pk), {user["external_id"] for user in payload["users"]}
@@ -221,7 +244,7 @@ def test_api_users_requires_post(self) -> None:
221244
"payload": dumps(
222245
{"since": ""},
223246
key=TEST_PAYMENT_SECRET,
224-
salt="weblate.user-sync",
247+
salt=USER_SYNC_SALT,
225248
)
226249
},
227250
)
@@ -252,7 +275,7 @@ def test_api_users_incremental(self) -> None:
252275
"payload": dumps(
253276
{"since": since.isoformat()},
254277
key=TEST_PAYMENT_SECRET,
255-
salt="weblate.user-sync",
278+
salt=USER_SYNC_SALT,
256279
)
257280
},
258281
)
@@ -261,7 +284,7 @@ def test_api_users_incremental(self) -> None:
261284
payload = loads(
262285
response.json()["payload"],
263286
key=TEST_PAYMENT_SECRET,
264-
salt="weblate.user-sync-response",
287+
salt=USER_SYNC_RESPONSE_SALT,
265288
)
266289
self.assertEqual(
267290
[user["external_id"] for user in payload["users"]],
@@ -279,7 +302,7 @@ def test_api_users_invalid_cursor(self) -> None:
279302
"payload": dumps(
280303
{"since": since},
281304
key=TEST_PAYMENT_SECRET,
282-
salt="weblate.user-sync",
305+
salt=USER_SYNC_SALT,
283306
)
284307
},
285308
)
@@ -290,8 +313,177 @@ def test_api_users_invalid_cursor(self) -> None:
290313
def test_api_users_no_payment_secret(self) -> None:
291314
response = self.client.post(
292315
reverse("hosted-api-users"),
293-
{"payload": dumps({"since": ""}, key="", salt="weblate.user-sync")},
316+
{"payload": dumps({"since": ""}, key="", salt=USER_SYNC_SALT)},
317+
)
318+
319+
self.assertEqual(response.status_code, 400)
320+
321+
@override_settings(
322+
ENABLE_HTTPS=True,
323+
PAYMENT_SECRET=TEST_PAYMENT_SECRET,
324+
SITE_DOMAIN="hosted.example.org",
325+
)
326+
def test_api_user_ensure_creates_user(self) -> None:
327+
mail.outbox.clear()
328+
with (
329+
patch("wlhosted.integrations.tasks.notify_user_change.delay"),
330+
self.captureOnCommitCallbacks(execute=True),
331+
):
332+
response = self.client.post(
333+
reverse("hosted-api-user-ensure"),
334+
{
335+
"payload": dumps(
336+
{
337+
"email": "created@example.org",
338+
"full_name": "Created User",
339+
},
340+
key=TEST_PAYMENT_SECRET,
341+
salt=USER_ENSURE_SALT,
342+
)
343+
},
344+
)
345+
346+
self.assertEqual(response.status_code, 200)
347+
user = User.objects.get(email="created@example.org")
348+
self.assertEqual(user.username, "created")
349+
self.assertEqual(user.full_name, "Created User")
350+
self.assertFalse(user.has_usable_password())
351+
payload = loads(
352+
response.json()["payload"],
353+
key=TEST_PAYMENT_SECRET,
354+
salt=USER_ENSURE_RESPONSE_SALT,
355+
)
356+
self.assertTrue(payload["created"])
357+
self.assertEqual(payload["user"]["external_id"], str(user.pk))
358+
359+
audit = AuditLog.objects.get(user=user, activity="external-create")
360+
self.assertIn("created externally from weblate.org", audit.get_message())
361+
self.assertEqual(len(mail.outbox), 1)
362+
self.assertIn(
363+
"Weblate.org has created this Hosted Weblate sign-in account",
364+
mail.outbox[0].body,
294365
)
366+
self.assertIn("purchased Weblate services", mail.outbox[0].body)
367+
self.assertIn("Set account password", " ".join(mail.outbox[0].body.split()))
368+
self.assertIn("verification_code=", mail.outbox[0].body)
369+
self.assertIn(
370+
f"https://hosted.example.org{reverse('social:complete', args=('email',))}",
371+
mail.outbox[0].body,
372+
)
373+
374+
@override_settings(
375+
ENABLE_HTTPS=True,
376+
PAYMENT_SECRET=TEST_PAYMENT_SECRET,
377+
SITE_DOMAIN="hosted.example.org",
378+
)
379+
def test_api_user_ensure_uses_concurrently_created_user(self) -> None:
380+
def create_concurrent_user(email: str) -> str:
381+
User.objects.create_user(
382+
username="created",
383+
email=email,
384+
password=TESTPASSWORD,
385+
full_name="Created Elsewhere",
386+
)
387+
return "created-1"
388+
389+
mail.outbox.clear()
390+
with (
391+
patch("wlhosted.integrations.tasks.notify_user_change.delay"),
392+
patch(
393+
"wlhosted.integrations.views.make_unique_username",
394+
side_effect=create_concurrent_user,
395+
),
396+
):
397+
response = self.client.post(
398+
reverse("hosted-api-user-ensure"),
399+
{
400+
"payload": dumps(
401+
{
402+
"email": "created@example.org",
403+
"full_name": "Created User",
404+
},
405+
key=TEST_PAYMENT_SECRET,
406+
salt=USER_ENSURE_SALT,
407+
)
408+
},
409+
)
410+
411+
self.assertEqual(response.status_code, 200)
412+
user = User.objects.get(email="created@example.org")
413+
payload = loads(
414+
response.json()["payload"],
415+
key=TEST_PAYMENT_SECRET,
416+
salt=USER_ENSURE_RESPONSE_SALT,
417+
)
418+
self.assertFalse(payload["created"])
419+
self.assertEqual(payload["user"]["external_id"], str(user.pk))
420+
self.assertEqual(user.full_name, "Created Elsewhere")
421+
self.assertEqual(len(mail.outbox), 0)
422+
423+
@override_settings(
424+
PAYMENT_SECRET=TEST_PAYMENT_SECRET,
425+
SITE_DOMAIN="hosted.example.org",
426+
)
427+
def test_api_user_ensure_uses_existing_user(self) -> None:
428+
with patch("wlhosted.integrations.tasks.notify_user_change.delay"):
429+
existing = User.objects.create_user(
430+
username="existing",
431+
email="existing@example.org",
432+
password=TESTPASSWORD,
433+
full_name="Existing User",
434+
)
435+
mail.outbox.clear()
436+
437+
response = self.client.post(
438+
reverse("hosted-api-user-ensure"),
439+
{
440+
"payload": dumps(
441+
{
442+
"email": "existing@example.org",
443+
"full_name": "Changed User",
444+
},
445+
key=TEST_PAYMENT_SECRET,
446+
salt=USER_ENSURE_SALT,
447+
)
448+
},
449+
)
450+
451+
self.assertEqual(response.status_code, 200)
452+
existing.refresh_from_db()
453+
self.assertEqual(existing.full_name, "Existing User")
454+
payload = loads(
455+
response.json()["payload"],
456+
key=TEST_PAYMENT_SECRET,
457+
salt=USER_ENSURE_RESPONSE_SALT,
458+
)
459+
self.assertFalse(payload["created"])
460+
self.assertEqual(payload["user"]["external_id"], str(existing.pk))
461+
self.assertEqual(len(mail.outbox), 0)
462+
self.assertFalse(
463+
AuditLog.objects.filter(user=existing, activity="external-create").exists()
464+
)
465+
466+
def test_make_unique_username_canonicalizes_email_case(self) -> None:
467+
self.assertEqual(make_unique_username("Created@Example.ORG"), "created")
468+
469+
@override_settings(PAYMENT_SECRET=TEST_PAYMENT_SECRET)
470+
def test_api_user_ensure_rejects_invalid_payload(self) -> None:
471+
response = self.client.post(
472+
reverse("hosted-api-user-ensure"),
473+
{
474+
"payload": dumps(
475+
{"email": "not-an-email", "full_name": "Invalid"},
476+
key=TEST_PAYMENT_SECRET,
477+
salt=USER_ENSURE_SALT,
478+
)
479+
},
480+
)
481+
482+
self.assertEqual(response.status_code, 400)
483+
484+
@override_settings(PAYMENT_SECRET="")
485+
def test_api_user_ensure_no_payment_secret(self) -> None:
486+
response = self.client.post(reverse("hosted-api-user-ensure"))
295487

296488
self.assertEqual(response.status_code, 400)
297489

@@ -479,7 +671,7 @@ def run_recurring(self, *, add_project: bool = True, add_user: bool = True) -> N
479671
bill = Billing.objects.get()
480672
project = Project.objects.create(name="Project", slug="project")
481673
if add_project:
482-
bill.projects.add(project)
674+
bill.add_project(project)
483675
if add_user:
484676
project.add_user(self.user)
485677
# Invoke recurring payment

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)