Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions wlhosted/integrations/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,22 @@
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from weblate.billing.models import Billing, Plan
from weblate.trans.defines import FULLNAME_LENGTH
from weblate.utils.validators import validate_fullname

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


class EnsureHostedUserForm(forms.Form):
email = forms.EmailField(label=_("E-mail"))
full_name = forms.CharField(
label=_("Full name"),
max_length=FULLNAME_LENGTH,
validators=[validate_fullname],
)


class ChooseBillingForm(forms.Form):
billing = forms.ModelChoiceField(
queryset=Billing.objects.none(),
Expand Down
3 changes: 2 additions & 1 deletion wlhosted/integrations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ def queue_user_sync(user: User, changes: dict[str, object] | None = None) -> Non
UserSyncState.objects.update_or_create(
user=user, defaults={"updated": timezone.now()}
)
notify_user_change.delay(get_user_sync_payload(user, changes))
payload = get_user_sync_payload(user, changes)
transaction.on_commit(lambda: notify_user_change.delay(payload))


@receiver(pre_save, sender=User)
Expand Down
2 changes: 1 addition & 1 deletion wlhosted/integrations/templates/hosted/create.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ <h3 class="card-title">{% translate "Current billing status" %}</h3>
</div>
{% endif %}
{% if billing %}
{% include "billing/status.html" with projects=billing.projects.all hide_buttons=True %}
{% include "billing/status.html" with projects=billing.all_projects hide_buttons=True %}
{% endif %}
</div>
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion wlhosted/integrations/templates/mail/billing_paid.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<p>{% translate "This billing plan covers following projects:" %}</p>

<ul>
{% for project in billing.projects.all %}
{% for project in billing.all_projects %}
<li>
<a href="{{ project.get_absolute_url }}">{{ project }}</a>
</li>
Expand Down
212 changes: 202 additions & 10 deletions wlhosted/integrations/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone
from weblate.accounts.models import AuditLog
from weblate.auth.models import User
from weblate.billing.models import Billing, Invoice, Plan
from weblate.trans.models import Project
Expand All @@ -44,6 +45,13 @@
pending_payments,
recurring_payments,
)
from wlhosted.integrations.views import (
USER_ENSURE_RESPONSE_SALT,
USER_ENSURE_SALT,
USER_SYNC_RESPONSE_SALT,
USER_SYNC_SALT,
make_unique_username,
)
from wlhosted.payments.backends import get_backend
from wlhosted.payments.models import Customer, Payment

Expand Down Expand Up @@ -97,7 +105,7 @@ def create_trial(self):
bill = Billing.objects.create(state=Billing.STATE_TRIAL, plan=self.plan_b)
bill.owners.add(self.user)
project = Project.objects.create(name="Project", slug="project")
bill.projects.add(project)
bill.add_project(project)
project.add_user(self.user)
return bill

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

with patch("wlhosted.integrations.tasks.notify_user_change.delay") as delay:
with (
patch("wlhosted.integrations.tasks.notify_user_change.delay") as delay,
self.captureOnCommitCallbacks(execute=True),
):
queue_user_sync(self.user)

sync_state.refresh_from_db()
self.assertGreater(sync_state.updated, old_updated)
delay.assert_called_once()

@override_settings(PAYMENT_SECRET=TEST_PAYMENT_SECRET)
def test_queue_user_sync_notifies_after_commit(self) -> None:
with (
patch("wlhosted.integrations.tasks.notify_user_change.delay") as delay,
self.captureOnCommitCallbacks(execute=False) as callbacks,
):
queue_user_sync(self.user)
delay.assert_not_called()

self.assertEqual(len(callbacks), 1)
delay.assert_not_called()

@override_settings(PAYMENT_SECRET="")
def test_queue_user_sync_no_payment_secret(self) -> None:
UserSyncState.objects.filter(user=self.user).delete()
Expand Down Expand Up @@ -197,7 +220,7 @@ def test_api_users(self) -> None:
"payload": dumps(
{"since": ""},
key=TEST_PAYMENT_SECRET,
salt="weblate.user-sync",
salt=USER_SYNC_SALT,
)
},
)
Expand All @@ -207,7 +230,7 @@ def test_api_users(self) -> None:
payload = loads(
response.json()["payload"],
key=TEST_PAYMENT_SECRET,
salt="weblate.user-sync-response",
salt=USER_SYNC_RESPONSE_SALT,
)
self.assertIn(
str(self.user.pk), {user["external_id"] for user in payload["users"]}
Expand All @@ -221,7 +244,7 @@ def test_api_users_requires_post(self) -> None:
"payload": dumps(
{"since": ""},
key=TEST_PAYMENT_SECRET,
salt="weblate.user-sync",
salt=USER_SYNC_SALT,
)
},
)
Expand Down Expand Up @@ -252,7 +275,7 @@ def test_api_users_incremental(self) -> None:
"payload": dumps(
{"since": since.isoformat()},
key=TEST_PAYMENT_SECRET,
salt="weblate.user-sync",
salt=USER_SYNC_SALT,
)
},
)
Expand All @@ -261,7 +284,7 @@ def test_api_users_incremental(self) -> None:
payload = loads(
response.json()["payload"],
key=TEST_PAYMENT_SECRET,
salt="weblate.user-sync-response",
salt=USER_SYNC_RESPONSE_SALT,
)
self.assertEqual(
[user["external_id"] for user in payload["users"]],
Expand All @@ -279,7 +302,7 @@ def test_api_users_invalid_cursor(self) -> None:
"payload": dumps(
{"since": since},
key=TEST_PAYMENT_SECRET,
salt="weblate.user-sync",
salt=USER_SYNC_SALT,
)
},
)
Expand All @@ -290,8 +313,177 @@ def test_api_users_invalid_cursor(self) -> None:
def test_api_users_no_payment_secret(self) -> None:
response = self.client.post(
reverse("hosted-api-users"),
{"payload": dumps({"since": ""}, key="", salt="weblate.user-sync")},
{"payload": dumps({"since": ""}, key="", salt=USER_SYNC_SALT)},
)

self.assertEqual(response.status_code, 400)

@override_settings(
ENABLE_HTTPS=True,
PAYMENT_SECRET=TEST_PAYMENT_SECRET,
SITE_DOMAIN="hosted.example.org",
)
def test_api_user_ensure_creates_user(self) -> None:
mail.outbox.clear()
with (
patch("wlhosted.integrations.tasks.notify_user_change.delay"),
self.captureOnCommitCallbacks(execute=True),
):
response = self.client.post(
reverse("hosted-api-user-ensure"),
{
"payload": dumps(
{
"email": "created@example.org",
"full_name": "Created User",
},
key=TEST_PAYMENT_SECRET,
salt=USER_ENSURE_SALT,
)
},
)

self.assertEqual(response.status_code, 200)
user = User.objects.get(email="created@example.org")
self.assertEqual(user.username, "created")
self.assertEqual(user.full_name, "Created User")
self.assertFalse(user.has_usable_password())
payload = loads(
response.json()["payload"],
key=TEST_PAYMENT_SECRET,
salt=USER_ENSURE_RESPONSE_SALT,
)
self.assertTrue(payload["created"])
self.assertEqual(payload["user"]["external_id"], str(user.pk))

audit = AuditLog.objects.get(user=user, activity="external-create")
self.assertIn("created externally from weblate.org", audit.get_message())
self.assertEqual(len(mail.outbox), 1)
self.assertIn(
"Weblate.org has created this Hosted Weblate sign-in account",
mail.outbox[0].body,
)
self.assertIn("purchased Weblate services", mail.outbox[0].body)
self.assertIn("Set account password", " ".join(mail.outbox[0].body.split()))
self.assertIn("verification_code=", mail.outbox[0].body)
self.assertIn(
f"https://hosted.example.org{reverse('social:complete', args=('email',))}",
mail.outbox[0].body,
)

@override_settings(
ENABLE_HTTPS=True,
PAYMENT_SECRET=TEST_PAYMENT_SECRET,
SITE_DOMAIN="hosted.example.org",
)
def test_api_user_ensure_uses_concurrently_created_user(self) -> None:
def create_concurrent_user(email: str) -> str:
User.objects.create_user(
username="created",
email=email,
password=TESTPASSWORD,
full_name="Created Elsewhere",
)
return "created-1"

mail.outbox.clear()
with (
patch("wlhosted.integrations.tasks.notify_user_change.delay"),
patch(
"wlhosted.integrations.views.make_unique_username",
side_effect=create_concurrent_user,
),
):
response = self.client.post(
reverse("hosted-api-user-ensure"),
{
"payload": dumps(
{
"email": "created@example.org",
"full_name": "Created User",
},
key=TEST_PAYMENT_SECRET,
salt=USER_ENSURE_SALT,
)
},
)

self.assertEqual(response.status_code, 200)
user = User.objects.get(email="created@example.org")
payload = loads(
response.json()["payload"],
key=TEST_PAYMENT_SECRET,
salt=USER_ENSURE_RESPONSE_SALT,
)
self.assertFalse(payload["created"])
self.assertEqual(payload["user"]["external_id"], str(user.pk))
self.assertEqual(user.full_name, "Created Elsewhere")
self.assertEqual(len(mail.outbox), 0)

@override_settings(
PAYMENT_SECRET=TEST_PAYMENT_SECRET,
SITE_DOMAIN="hosted.example.org",
)
def test_api_user_ensure_uses_existing_user(self) -> None:
with patch("wlhosted.integrations.tasks.notify_user_change.delay"):
existing = User.objects.create_user(
username="existing",
email="existing@example.org",
password=TESTPASSWORD,
full_name="Existing User",
)
mail.outbox.clear()

response = self.client.post(
reverse("hosted-api-user-ensure"),
{
"payload": dumps(
{
"email": "existing@example.org",
"full_name": "Changed User",
},
key=TEST_PAYMENT_SECRET,
salt=USER_ENSURE_SALT,
)
},
)

self.assertEqual(response.status_code, 200)
existing.refresh_from_db()
self.assertEqual(existing.full_name, "Existing User")
payload = loads(
response.json()["payload"],
key=TEST_PAYMENT_SECRET,
salt=USER_ENSURE_RESPONSE_SALT,
)
self.assertFalse(payload["created"])
self.assertEqual(payload["user"]["external_id"], str(existing.pk))
self.assertEqual(len(mail.outbox), 0)
self.assertFalse(
AuditLog.objects.filter(user=existing, activity="external-create").exists()
)

def test_make_unique_username_canonicalizes_email_case(self) -> None:
self.assertEqual(make_unique_username("Created@Example.ORG"), "created")

@override_settings(PAYMENT_SECRET=TEST_PAYMENT_SECRET)
def test_api_user_ensure_rejects_invalid_payload(self) -> None:
response = self.client.post(
reverse("hosted-api-user-ensure"),
{
"payload": dumps(
{"email": "not-an-email", "full_name": "Invalid"},
key=TEST_PAYMENT_SECRET,
salt=USER_ENSURE_SALT,
)
},
)

self.assertEqual(response.status_code, 400)

@override_settings(PAYMENT_SECRET="")
def test_api_user_ensure_no_payment_secret(self) -> None:
response = self.client.post(reverse("hosted-api-user-ensure"))

self.assertEqual(response.status_code, 400)

Expand Down Expand Up @@ -479,7 +671,7 @@ def run_recurring(self, *, add_project: bool = True, add_user: bool = True) -> N
bill = Billing.objects.get()
project = Project.objects.create(name="Project", slug="project")
if add_project:
bill.projects.add(project)
bill.add_project(project)
if add_user:
project.add_user(self.user)
# Invoke recurring payment
Expand Down
3 changes: 2 additions & 1 deletion wlhosted/integrations/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@

from django.urls import path

from wlhosted.integrations.views import api_users
from wlhosted.integrations.views import api_user_ensure, api_users

urlpatterns = [
path("users/", api_users, name="hosted-api-users"),
path("users/ensure/", api_user_ensure, name="hosted-api-user-ensure"),
]
Loading
Loading