flaskbb-plugin-inviteonly 1.0.0__tar.gz → 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/PKG-INFO +2 -2
  2. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/__init__.py +0 -2
  3. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/forms.py +2 -2
  4. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/utils.py +20 -7
  5. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/views.py +7 -5
  6. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/pyproject.toml +2 -2
  7. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/tests/conftest.py +5 -0
  8. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/tests/test_inviteonly_views.py +64 -0
  9. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/.gitignore +0 -0
  10. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/LICENSE +0 -0
  11. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/README.md +0 -0
  12. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/migrations/202607301400_c3e7a1f9b8d2_add_inviteonly.py +0 -0
  13. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/migrations/__init__.py +0 -0
  14. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/models.py +0 -0
  15. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/py.typed +0 -0
  16. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/templates/inviteonly/_macros.html +0 -0
  17. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/templates/inviteonly/_navlink.html +0 -0
  18. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/templates/inviteonly/admin.html +0 -0
  19. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/templates/inviteonly/enter_code.html +0 -0
  20. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/templates/inviteonly/mine.html +0 -0
  21. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/inviteonly/templates/inviteonly/register.html +0 -0
  22. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/tests/test_inviteonly_models.py +0 -0
  23. {flaskbb_plugin_inviteonly-1.0.0 → flaskbb_plugin_inviteonly-1.0.2}/tests/test_inviteonly_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flaskbb-plugin-inviteonly
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Invite-only registration for FlaskBB - let admins and quota-holding members invite new users even while registration is closed
5
5
  Project-URL: Homepage, https://github.com/flaskbb/flaskbb-plugin-inviteonly
6
6
  Project-URL: Repository, https://github.com/flaskbb/flaskbb-plugin-inviteonly
@@ -23,7 +23,7 @@ Classifier: Programming Language :: Python :: 3.14
23
23
  Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
24
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
25
  Requires-Python: >=3.12
26
- Requires-Dist: flaskbb>=3.0.0
26
+ Requires-Dist: flaskbb>=3.0.0rc4
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  Invite-only
@@ -31,8 +31,6 @@ from .utils import (
31
31
  )
32
32
  from .views import inviteonly_bp
33
33
 
34
- __version__ = "1.0.0"
35
-
36
34
  hookimpl = HookimplMarker("flaskbb")
37
35
 
38
36
 
@@ -13,7 +13,7 @@ from flask_babelplus import gettext as _
13
13
  from flask_wtf import FlaskForm
14
14
  from flaskbb.utils.forms import FlaskBBForm
15
15
  from wtforms import BooleanField, IntegerField, StringField, SubmitField
16
- from wtforms.validators import DataRequired, Length, NumberRange, Optional
16
+ from wtforms.validators import DataRequired, Email, Length, NumberRange, Optional
17
17
 
18
18
 
19
19
  class InviteCodeForm(FlaskBBForm):
@@ -27,7 +27,7 @@ class InviteCodeForm(FlaskBBForm):
27
27
  class CreateInviteForm(FlaskBBForm):
28
28
  email = StringField(
29
29
  _("Email address (optional)"),
30
- validators=[Optional(), Length(max=255)],
30
+ validators=[Optional(), Length(max=255), Email(message=_("Invalid email address."))],
31
31
  )
32
32
  submit = SubmitField(_("Create invitation"))
33
33
 
@@ -9,16 +9,20 @@ emailing invites.
9
9
  :license: BSD License, see LICENSE for more details.
10
10
  """
11
11
 
12
+ import logging
12
13
  import secrets
13
14
 
14
15
  from flask_allows2 import Permission
15
- from flaskbb.email import send_email
16
+ from flaskbb.email import send_async_email, send_email
16
17
  from flaskbb.settings import flaskbb_config
17
18
  from flaskbb.user.models import User
18
19
  from flaskbb.utils.requirements import IsAdmin
20
+ from kombu.exceptions import OperationalError
19
21
 
20
22
  from .models import Invitation, InviteQuota
21
23
 
24
+ logger = logging.getLogger(__name__)
25
+
22
26
  CODE_LENGTH = 10
23
27
 
24
28
  # Setting defaults - also used as the SettingGroup's defaults in __init__.py,
@@ -103,15 +107,24 @@ def grant_quota(user: User, remaining: int | None) -> None:
103
107
 
104
108
 
105
109
  def send_invite_email(email: str, link: str, invited_by: User) -> None:
106
- send_email(
107
- subject="You've been invited to join the forum",
108
- recipients=[email],
109
- text_body=(
110
+ message = {
111
+ "subject": "You've been invited to join the forum",
112
+ "recipients": [email],
113
+ "text_body": (
110
114
  f"{invited_by.username} has invited you to join the forum.\n\n"
111
115
  f"Follow this link to register: {link}\n"
112
116
  ),
113
- html_body=(
117
+ "html_body": (
114
118
  f"<p>{invited_by.username} has invited you to join the forum.</p>"
115
119
  f'<p><a href="{link}">{link}</a></p>'
116
120
  ),
117
- )
121
+ }
122
+ # TODO: replace with flaskbb.email.delay_or_run once FlaskBB 3.0 is out
123
+ try:
124
+ send_async_email.delay(**message)
125
+ except OperationalError:
126
+ logger.warning("Could not queue the invitation mail, sending it in-process")
127
+ try:
128
+ send_email(**message)
129
+ except Exception:
130
+ logger.exception("Could not send the invitation mail to %s", email)
@@ -77,7 +77,9 @@ class EnterInviteCode(MethodView):
77
77
  def post(self):
78
78
  form = InviteCodeForm()
79
79
  if form.validate_on_submit():
80
- return redirect(url_for("inviteonly.register_with_invite", code=form.code.data.strip()))
80
+ return redirect(
81
+ url_for("inviteonly.register_with_invite", code=(form.code.data or "").strip())
82
+ )
81
83
  return render_template("inviteonly/enter_code.html", form=form)
82
84
 
83
85
 
@@ -124,10 +126,10 @@ class RegisterWithInvite(MethodView):
124
126
  form = self.form(invite)
125
127
  if form.validate_on_submit():
126
128
  registration_info = UserRegistrationInfo(
127
- username=form.username.data,
128
- password=form.password.data,
129
+ username=form.username.data or "",
130
+ password=form.password.data or "",
129
131
  group=4,
130
- email=form.email.data,
132
+ email=form.email.data or "",
131
133
  language=form.language.data,
132
134
  )
133
135
 
@@ -309,7 +311,7 @@ class AdminGrantQuota(MethodView):
309
311
  form = AdminGrantQuotaForm()
310
312
 
311
313
  if form.validate_on_submit():
312
- target = User.get(func.lower(User.username) == form.username.data.lower())
314
+ target = User.get(func.lower(User.username) == (form.username.data or "").lower())
313
315
  if target is None:
314
316
  flash(
315
317
  _("No user named %(username)s.", username=form.username.data),
@@ -1,8 +1,8 @@
1
1
  [project]
2
2
  name = "flaskbb-plugin-inviteonly"
3
- version = "1.0.0"
3
+ version = "1.0.2"
4
4
  dependencies = [
5
- "FlaskBB>=3.0.0",
5
+ "FlaskBB>=3.0.0rc4",
6
6
  ]
7
7
  requires-python = ">=3.12"
8
8
  authors = [
@@ -7,6 +7,11 @@ from inviteonly.models import Invitation
7
7
  from inviteonly.utils import generate_code
8
8
 
9
9
 
10
+ @pytest.fixture(scope="package")
11
+ def tested_plugins():
12
+ return ["inviteonly"]
13
+
14
+
10
15
  @pytest.fixture
11
16
  def invite(user):
12
17
  invitation = Invitation(code=generate_code(), created_by_id=user.id)
@@ -2,9 +2,12 @@ from contextlib import contextmanager
2
2
 
3
3
  import pytest
4
4
  from flask_login import login_user, logout_user
5
+ from flaskbb.email import send_async_email
6
+ from flaskbb.extensions import mail
5
7
  from flaskbb.settings import flaskbb_config
6
8
  from flaskbb.user.models import User
7
9
  from flaskbb.utils.helpers import time_utcnow
10
+ from kombu.exceptions import OperationalError
8
11
  from werkzeug.exceptions import NotFound
9
12
 
10
13
  from inviteonly.models import Invitation
@@ -112,6 +115,23 @@ def test_create_invite_blocked_when_quota_exhausted(application, database, user)
112
115
  assert Invitation.get_all(Invitation.created_by_id == user.id) == []
113
116
 
114
117
 
118
+ def test_create_invite_rejects_an_invalid_email(application, database, default_settings, user):
119
+ grant_quota(user, 1)
120
+
121
+ view = MyInvitations.as_view("mine")
122
+ with (
123
+ _csrf_disabled(application),
124
+ application.test_request_context(
125
+ method="POST", data={"email": "not-an-email"}, path="/invite/mine"
126
+ ),
127
+ _logged_in(user),
128
+ ):
129
+ resp = view()
130
+
131
+ assert "Invalid email address." in resp
132
+ assert Invitation.get_all(Invitation.created_by_id == user.id) == []
133
+
134
+
115
135
  def test_revoke_invite_by_non_owner_non_admin_is_rejected(
116
136
  application, database, invite, other_moderator_user
117
137
  ):
@@ -160,3 +180,47 @@ def test_admin_invitations_allows_admin(application, database, default_settings,
160
180
  resp = view()
161
181
 
162
182
  assert isinstance(resp, str)
183
+
184
+
185
+ def _create_invite(application, user, email):
186
+ view = MyInvitations.as_view("mine")
187
+ with (
188
+ _csrf_disabled(application),
189
+ application.test_request_context(method="POST", data={"email": email}, path="/invite/mine"),
190
+ _logged_in(user),
191
+ ):
192
+ return view()
193
+
194
+
195
+ def test_invite_mail_is_queued(application, database, default_settings, admin_user, mocker):
196
+ delay = mocker.patch.object(send_async_email, "delay")
197
+
198
+ resp = _create_invite(application, admin_user, "friend@example.org")
199
+
200
+ assert resp.status_code == 302
201
+ delay.assert_called_once()
202
+ assert delay.call_args.kwargs["recipients"] == ["friend@example.org"]
203
+
204
+
205
+ def test_invite_mail_is_sent_in_process_without_broker(
206
+ application, database, default_settings, admin_user, mocker
207
+ ):
208
+ mocker.patch.object(send_async_email, "delay", side_effect=OperationalError)
209
+
210
+ with mail.record_messages() as outbox:
211
+ resp = _create_invite(application, admin_user, "friend@example.org")
212
+
213
+ assert resp.status_code == 302
214
+ assert [message.recipients for message in outbox] == [["friend@example.org"]]
215
+
216
+
217
+ def test_failing_invite_mail_keeps_the_invitation(
218
+ application, database, default_settings, admin_user, mocker
219
+ ):
220
+ mocker.patch.object(send_async_email, "delay", side_effect=OperationalError)
221
+ mocker.patch.object(mail, "send", side_effect=ConnectionRefusedError)
222
+
223
+ resp = _create_invite(application, admin_user, "friend@example.org")
224
+
225
+ assert resp.status_code == 302
226
+ assert Invitation.get(Invitation.invited_email == "friend@example.org") is not None