flaskbb-plugin-inviteonly 1.0.0__py3-none-any.whl
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.
- flaskbb_plugin_inviteonly-1.0.0.dist-info/METADATA +91 -0
- flaskbb_plugin_inviteonly-1.0.0.dist-info/RECORD +19 -0
- flaskbb_plugin_inviteonly-1.0.0.dist-info/WHEEL +4 -0
- flaskbb_plugin_inviteonly-1.0.0.dist-info/entry_points.txt +2 -0
- flaskbb_plugin_inviteonly-1.0.0.dist-info/licenses/LICENSE +31 -0
- inviteonly/__init__.py +118 -0
- inviteonly/forms.py +54 -0
- inviteonly/migrations/202607301400_c3e7a1f9b8d2_add_inviteonly.py +84 -0
- inviteonly/migrations/__init__.py +0 -0
- inviteonly/models.py +74 -0
- inviteonly/py.typed +0 -0
- inviteonly/templates/inviteonly/_macros.html +11 -0
- inviteonly/templates/inviteonly/_navlink.html +12 -0
- inviteonly/templates/inviteonly/admin.html +90 -0
- inviteonly/templates/inviteonly/enter_code.html +22 -0
- inviteonly/templates/inviteonly/mine.html +76 -0
- inviteonly/templates/inviteonly/register.html +40 -0
- inviteonly/utils.py +117 -0
- inviteonly/views.py +364 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: flaskbb-plugin-inviteonly
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Invite-only registration for FlaskBB - let admins and quota-holding members invite new users even while registration is closed
|
|
5
|
+
Project-URL: Homepage, https://github.com/flaskbb/flaskbb-plugin-inviteonly
|
|
6
|
+
Project-URL: Repository, https://github.com/flaskbb/flaskbb-plugin-inviteonly
|
|
7
|
+
Project-URL: Issues, https://github.com/flaskbb/flaskbb-plugin-inviteonly/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/flaskbb/flaskbb-plugin-inviteonly/blob/master/CHANGELOG.md
|
|
9
|
+
Author-email: Peter Justin <peter@peterjustin.com>
|
|
10
|
+
Maintainer-email: Peter Justin <peter@peterjustin.com>
|
|
11
|
+
License-Expression: BSD-3-Clause
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: flaskbb,invitation,invite,plugin,registration
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Web Environment
|
|
16
|
+
Classifier: Framework :: Flask
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Requires-Python: >=3.12
|
|
26
|
+
Requires-Dist: flaskbb>=3.0.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
Invite-only
|
|
30
|
+
===========
|
|
31
|
+
|
|
32
|
+
Invite-only registration for FlaskBB. Lets an admin, or a member with an
|
|
33
|
+
invitation quota, generate an invitation code/link (optionally emailed)
|
|
34
|
+
that lets someone register even while registration is otherwise closed.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Installation
|
|
38
|
+
------------
|
|
39
|
+
|
|
40
|
+
Install *flaskbb-plugin-inviteonly* with
|
|
41
|
+
``pip install flaskbb-plugin-inviteonly``
|
|
42
|
+
|
|
43
|
+
For local development against a FlaskBB checkout, see "Developing a new,
|
|
44
|
+
standalone plugin" in FlaskBB's own
|
|
45
|
+
[plugin development docs](https://flaskbb.readthedocs.io/en/latest/development/plugin/developing.html) -
|
|
46
|
+
in short, from FlaskBB's root folder:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
uv pip install -e ../flaskbb-plugin-inviteonly
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
Usage
|
|
54
|
+
-----
|
|
55
|
+
|
|
56
|
+
Any logged-in member with a nonzero (or unlimited) invitation quota can
|
|
57
|
+
create invitations from "My Invitations" (linked in the user menu). Each
|
|
58
|
+
invitation is a one-time code/link; an email address can optionally be
|
|
59
|
+
given to send it directly, otherwise the code/link is shown to copy and
|
|
60
|
+
share manually.
|
|
61
|
+
|
|
62
|
+
The recipient registers either by following the link
|
|
63
|
+
(`/invite/<code>`) or by entering the code on the "Have an invitation
|
|
64
|
+
code?" page (`/invite`) - both work even while FlaskBB's own
|
|
65
|
+
registration is closed (`REGISTRATION_ENABLED` off).
|
|
66
|
+
|
|
67
|
+
Admins manage every invitation and grant/adjust any user's quota from
|
|
68
|
+
the admin panel's "Invitations" page.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
Settings
|
|
72
|
+
--------
|
|
73
|
+
|
|
74
|
+
*Invite-only Settings*, under the admin panel's Plugin Settings, has
|
|
75
|
+
three options:
|
|
76
|
+
|
|
77
|
+
* **Enable invite-only registration** - master toggle for the invitation
|
|
78
|
+
routes (default: on).
|
|
79
|
+
* **Default invitation quota** - how many invitations a user can create
|
|
80
|
+
before an admin has granted them a quota (default: 0, meaning
|
|
81
|
+
invitations are admin-only until granted).
|
|
82
|
+
* **Invitation expiry (days)** - how long an invitation stays valid
|
|
83
|
+
(default: 7, 0 means it never expires).
|
|
84
|
+
|
|
85
|
+
Admins always bypass the quota check and can create invitations
|
|
86
|
+
regardless of their own quota.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
License
|
|
90
|
+
-------
|
|
91
|
+
This project is licensed under the terms of the [BSD License](/LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
inviteonly/__init__.py,sha256=BSURCEIcL24uIwtxH1GUMAY4oLvhxfVWfzz7uFKgYlk,3437
|
|
2
|
+
inviteonly/forms.py,sha256=D8VV0zGHoGO-1woz7q1Kb6GIsbY6ynZSZ5gr6yc-jPY,1569
|
|
3
|
+
inviteonly/models.py,sha256=9svlhZTg2BStq6tZOge9ffKTyvJjq6zL6d08DWZV8NU,2488
|
|
4
|
+
inviteonly/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
inviteonly/utils.py,sha256=XM1x2RAP2BXOvGIIZZodT1xSMyEHWHxf8-qqIPaTw8U,3626
|
|
6
|
+
inviteonly/views.py,sha256=7xKWPH6NFXkN95JObx15iRs1YCmQXig0f4p4pvxgiFE,11534
|
|
7
|
+
inviteonly/migrations/202607301400_c3e7a1f9b8d2_add_inviteonly.py,sha256=s3iqd-8OoROV1tJRfqHi5JKAuQAn8UhATkS8jnitRGY,2866
|
|
8
|
+
inviteonly/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
inviteonly/templates/inviteonly/_macros.html,sha256=qxY4dOnyBE779TIIY3REi33cvI-LqB3rFGdigFB8mGY,460
|
|
10
|
+
inviteonly/templates/inviteonly/_navlink.html,sha256=yRFJnz1VWHiDY-DFeHX84jbbgrs80ugxIFnkDJPIpCo,432
|
|
11
|
+
inviteonly/templates/inviteonly/admin.html,sha256=pYsdqpfslq-D3dN-P1I1FV8keQC127IN-DZUqJmzjBs,4494
|
|
12
|
+
inviteonly/templates/inviteonly/enter_code.html,sha256=aBHKb7yqJOX_b89Mx9tXgNSsuNjI_n_B_i01PpH63-w,624
|
|
13
|
+
inviteonly/templates/inviteonly/mine.html,sha256=u7I-o7K_HEnWuksPJkqfol7CJmPyU1QZrb_rFL9zZJ4,3888
|
|
14
|
+
inviteonly/templates/inviteonly/register.html,sha256=6hzBi34MhPjkgFLrnSt-WmVcDq2JmCiVIP4Z1wjG2tM,1419
|
|
15
|
+
flaskbb_plugin_inviteonly-1.0.0.dist-info/METADATA,sha256=e3gMHcUvZ4jaIshHjWxZUFYumJdHD-e1YXdhQvjXuSc,3410
|
|
16
|
+
flaskbb_plugin_inviteonly-1.0.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
17
|
+
flaskbb_plugin_inviteonly-1.0.0.dist-info/entry_points.txt,sha256=RS0OgCBk4qfdM8K7evYssWPQjcfXfo9NqnxJnefbVCA,42
|
|
18
|
+
flaskbb_plugin_inviteonly-1.0.0.dist-info/licenses/LICENSE,sha256=ebP4t6o_-IT9SPVkjzUZTaUR3CX0QsZ8qLSFbbUSSUM,1500
|
|
19
|
+
flaskbb_plugin_inviteonly-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Copyright (c) 2026 by Peter Justin and contributors. See AUTHORS for more
|
|
2
|
+
details.
|
|
3
|
+
|
|
4
|
+
Some rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are
|
|
8
|
+
met:
|
|
9
|
+
|
|
10
|
+
* Redistributions of source code must retain the above copyright
|
|
11
|
+
notice, this list of conditions and the following disclaimer.
|
|
12
|
+
|
|
13
|
+
* Redistributions in binary form must reproduce the above copyright
|
|
14
|
+
notice, this list of conditions and the following disclaimer in the
|
|
15
|
+
documentation and/or other materials provided with the distribution.
|
|
16
|
+
|
|
17
|
+
* The names of the contributors may not be used to endorse or
|
|
18
|
+
promote products derived from this software without specific
|
|
19
|
+
prior written permission.
|
|
20
|
+
|
|
21
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
22
|
+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
23
|
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
24
|
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
25
|
+
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
26
|
+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
27
|
+
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
28
|
+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
29
|
+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
30
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
31
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
inviteonly/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
inviteonly
|
|
3
|
+
~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
An invite-only registration plugin for FlaskBB. Lets an admin or a user
|
|
6
|
+
with an invitation quota generate a code/link (optionally emailed) that
|
|
7
|
+
lets someone register even while registration is globally closed.
|
|
8
|
+
|
|
9
|
+
:copyright: (c) 2026 by Peter Justin.
|
|
10
|
+
:license: BSD License, see LICENSE for more details.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from flask import Flask
|
|
16
|
+
from flask_allows2 import Permission
|
|
17
|
+
from flask_login import current_user
|
|
18
|
+
from flaskbb.settings import BoolSetting, IntSetting, SettingGroup
|
|
19
|
+
from flaskbb.user.models import User
|
|
20
|
+
from flaskbb.utils.helpers import real, render_template
|
|
21
|
+
from flaskbb.utils.requirements import IsAdmin
|
|
22
|
+
from pluggy import HookimplMarker
|
|
23
|
+
|
|
24
|
+
from .utils import (
|
|
25
|
+
DEFAULT_ENABLED,
|
|
26
|
+
DEFAULT_EXPIRY_DAYS,
|
|
27
|
+
DEFAULT_QUOTA,
|
|
28
|
+
default_quota,
|
|
29
|
+
display_remaining,
|
|
30
|
+
grant_quota,
|
|
31
|
+
)
|
|
32
|
+
from .views import inviteonly_bp
|
|
33
|
+
|
|
34
|
+
__version__ = "1.0.0"
|
|
35
|
+
|
|
36
|
+
hookimpl = HookimplMarker("flaskbb")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@hookimpl
|
|
40
|
+
def flaskbb_load_migrations():
|
|
41
|
+
return os.path.join(os.path.dirname(__file__), "migrations")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@hookimpl
|
|
45
|
+
def flaskbb_load_translations():
|
|
46
|
+
return os.path.join(os.path.dirname(__file__), "translations")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@hookimpl
|
|
50
|
+
def flaskbb_load_blueprints(app: Flask):
|
|
51
|
+
app.register_blueprint(
|
|
52
|
+
inviteonly_bp,
|
|
53
|
+
url_prefix=app.config.get("PLUGIN_INVITEONLY_URL_PREFIX", "/invite"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
SETTINGS = SettingGroup(
|
|
58
|
+
key="inviteonly",
|
|
59
|
+
name="Invite-only Settings",
|
|
60
|
+
description="Settings for invite-only registration.",
|
|
61
|
+
settings=(
|
|
62
|
+
BoolSetting(
|
|
63
|
+
key="ENABLED",
|
|
64
|
+
value=DEFAULT_ENABLED,
|
|
65
|
+
name="Enable invite-only registration",
|
|
66
|
+
description=(
|
|
67
|
+
"When enabled, invitation links/codes let someone register "
|
|
68
|
+
"even while registration is otherwise closed."
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
IntSetting(
|
|
72
|
+
key="DEFAULT_QUOTA",
|
|
73
|
+
value=DEFAULT_QUOTA,
|
|
74
|
+
min=0,
|
|
75
|
+
name="Default invitation quota",
|
|
76
|
+
description=(
|
|
77
|
+
"How many invitations a user can create before an admin has "
|
|
78
|
+
"granted them a quota. 0 means invitations are admin-only "
|
|
79
|
+
"until a quota is granted."
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
IntSetting(
|
|
83
|
+
key="EXPIRY_DAYS",
|
|
84
|
+
value=DEFAULT_EXPIRY_DAYS,
|
|
85
|
+
min=0,
|
|
86
|
+
name="Invitation expiry (days)",
|
|
87
|
+
description=("How many days an invitation stays valid. 0 means it never expires."),
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@hookimpl
|
|
94
|
+
def flaskbb_load_setting_groups():
|
|
95
|
+
return SETTINGS
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@hookimpl
|
|
99
|
+
def flaskbb_registration_post_processor(user: User):
|
|
100
|
+
# Seeds a quota row for every newly registered user (open or
|
|
101
|
+
# invite-based - both paths call RegistrationService.register(), which
|
|
102
|
+
# fires this hook) so the default quota setting takes effect immediately
|
|
103
|
+
# instead of only lazily on first invite creation.
|
|
104
|
+
grant_quota(user, default_quota())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@hookimpl
|
|
108
|
+
def flaskbb_tpl_user_nav_loggedin_before():
|
|
109
|
+
user = real(current_user)
|
|
110
|
+
remaining = display_remaining(user, default_quota())
|
|
111
|
+
has_invites = remaining is None or remaining > 0
|
|
112
|
+
return render_template("inviteonly/_navlink.html", remaining=remaining, has_invites=has_invites)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@hookimpl(trylast=True)
|
|
116
|
+
def flaskbb_tpl_admin_settings_menu(user: User):
|
|
117
|
+
if Permission(IsAdmin, identity=user):
|
|
118
|
+
return [("inviteonly.admin_invitations", "Invitations", "fa fa-envelope-open-text")]
|
inviteonly/forms.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
inviteonly.forms
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Forms used to enter an invite code, create an invite, revoke an invite,
|
|
6
|
+
and (for admins) set a user's invitation quota.
|
|
7
|
+
|
|
8
|
+
:copyright: (c) 2026 by Peter Justin.
|
|
9
|
+
:license: BSD License, see LICENSE for more details.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from flask_babelplus import gettext as _
|
|
13
|
+
from flask_wtf import FlaskForm
|
|
14
|
+
from flaskbb.utils.forms import FlaskBBForm
|
|
15
|
+
from wtforms import BooleanField, IntegerField, StringField, SubmitField
|
|
16
|
+
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InviteCodeForm(FlaskBBForm):
|
|
20
|
+
code = StringField(
|
|
21
|
+
_("Invitation code"),
|
|
22
|
+
validators=[DataRequired(message=_("Please enter an invitation code."))],
|
|
23
|
+
)
|
|
24
|
+
submit = SubmitField(_("Continue"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CreateInviteForm(FlaskBBForm):
|
|
28
|
+
email = StringField(
|
|
29
|
+
_("Email address (optional)"),
|
|
30
|
+
validators=[Optional(), Length(max=255)],
|
|
31
|
+
)
|
|
32
|
+
submit = SubmitField(_("Create invitation"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AdminQuotaForm(FlaskBBForm):
|
|
36
|
+
remaining = IntegerField(
|
|
37
|
+
_("Remaining invitations"),
|
|
38
|
+
validators=[Optional(), NumberRange(min=0)],
|
|
39
|
+
)
|
|
40
|
+
unlimited = BooleanField(_("Unlimited"), default=False)
|
|
41
|
+
submit = SubmitField(_("Save"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AdminGrantQuotaForm(AdminQuotaForm):
|
|
45
|
+
username = StringField(
|
|
46
|
+
_("Username"),
|
|
47
|
+
validators=[DataRequired(message=_("Please enter a username."))],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RevokeInviteForm(FlaskForm):
|
|
52
|
+
"""Carries the CSRF token for a revoke request - the invitation id
|
|
53
|
+
comes from the URL, so there's nothing else for this form to hold.
|
|
54
|
+
"""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Add inviteonly_invitations and inviteonly_quotas
|
|
2
|
+
|
|
3
|
+
Revision ID: c3e7a1f9b8d2
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-07-30 14:00:00.000000
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import flaskbb
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
from alembic import op
|
|
12
|
+
|
|
13
|
+
# revision identifiers, used by Alembic.
|
|
14
|
+
revision = "c3e7a1f9b8d2"
|
|
15
|
+
down_revision = None
|
|
16
|
+
branch_labels = ("inviteonly",)
|
|
17
|
+
depends_on = "8ad96e49dc6" # flaskbb core init migration - creates users
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def upgrade():
|
|
21
|
+
con = op.get_bind()
|
|
22
|
+
|
|
23
|
+
if not sa.inspect(con).has_table("inviteonly_invitations"):
|
|
24
|
+
op.create_table(
|
|
25
|
+
"inviteonly_invitations",
|
|
26
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
27
|
+
sa.Column("code", sa.String(length=64), nullable=False),
|
|
28
|
+
sa.Column("created_by_id", sa.Integer(), nullable=True),
|
|
29
|
+
sa.Column("invited_email", sa.String(length=255), nullable=True),
|
|
30
|
+
sa.Column(
|
|
31
|
+
"created_at",
|
|
32
|
+
flaskbb.utils.database.UTCDateTime(timezone=True),
|
|
33
|
+
nullable=False,
|
|
34
|
+
),
|
|
35
|
+
sa.Column(
|
|
36
|
+
"expires_at",
|
|
37
|
+
flaskbb.utils.database.UTCDateTime(timezone=True),
|
|
38
|
+
nullable=True,
|
|
39
|
+
),
|
|
40
|
+
sa.Column(
|
|
41
|
+
"accepted_at",
|
|
42
|
+
flaskbb.utils.database.UTCDateTime(timezone=True),
|
|
43
|
+
nullable=True,
|
|
44
|
+
),
|
|
45
|
+
sa.Column("accepted_by_id", sa.Integer(), nullable=True),
|
|
46
|
+
sa.Column(
|
|
47
|
+
"revoked_at",
|
|
48
|
+
flaskbb.utils.database.UTCDateTime(timezone=True),
|
|
49
|
+
nullable=True,
|
|
50
|
+
),
|
|
51
|
+
sa.ForeignKeyConstraint(
|
|
52
|
+
["created_by_id"],
|
|
53
|
+
["users.id"],
|
|
54
|
+
name=op.f("fk_inviteonly_invitations_created_by_id_users"),
|
|
55
|
+
ondelete="SET NULL",
|
|
56
|
+
),
|
|
57
|
+
sa.ForeignKeyConstraint(
|
|
58
|
+
["accepted_by_id"],
|
|
59
|
+
["users.id"],
|
|
60
|
+
name=op.f("fk_inviteonly_invitations_accepted_by_id_users"),
|
|
61
|
+
ondelete="SET NULL",
|
|
62
|
+
),
|
|
63
|
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_inviteonly_invitations")),
|
|
64
|
+
sa.UniqueConstraint("code", name=op.f("uq_inviteonly_invitations_code")),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if not sa.inspect(con).has_table("inviteonly_quotas"):
|
|
68
|
+
op.create_table(
|
|
69
|
+
"inviteonly_quotas",
|
|
70
|
+
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
71
|
+
sa.Column("remaining", sa.Integer(), nullable=True),
|
|
72
|
+
sa.ForeignKeyConstraint(
|
|
73
|
+
["user_id"],
|
|
74
|
+
["users.id"],
|
|
75
|
+
name=op.f("fk_inviteonly_quotas_user_id_users"),
|
|
76
|
+
ondelete="CASCADE",
|
|
77
|
+
),
|
|
78
|
+
sa.PrimaryKeyConstraint("user_id", name=op.f("pk_inviteonly_quotas")),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def downgrade():
|
|
83
|
+
op.drop_table("inviteonly_quotas")
|
|
84
|
+
op.drop_table("inviteonly_invitations")
|
|
File without changes
|
inviteonly/models.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
inviteonly.models
|
|
3
|
+
~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
The models for invitations and per-user invitation quotas.
|
|
6
|
+
|
|
7
|
+
:copyright: (c) 2026 by Peter Justin.
|
|
8
|
+
:license: BSD License, see LICENSE for more details.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import datetime
|
|
12
|
+
|
|
13
|
+
from flaskbb.utils.database import BaseModel, UTCDateTime
|
|
14
|
+
from flaskbb.utils.helpers import time_utcnow
|
|
15
|
+
from sqlalchemy import ForeignKey, Integer, String
|
|
16
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Invitation(BaseModel):
|
|
20
|
+
__tablename__ = "inviteonly_invitations"
|
|
21
|
+
|
|
22
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
23
|
+
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
|
24
|
+
created_by_id: Mapped[int | None] = mapped_column(
|
|
25
|
+
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
26
|
+
)
|
|
27
|
+
invited_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
28
|
+
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
29
|
+
UTCDateTime(timezone=True), default=time_utcnow, nullable=False
|
|
30
|
+
)
|
|
31
|
+
expires_at: Mapped[datetime.datetime | None] = mapped_column(
|
|
32
|
+
UTCDateTime(timezone=True), nullable=True
|
|
33
|
+
)
|
|
34
|
+
accepted_at: Mapped[datetime.datetime | None] = mapped_column(
|
|
35
|
+
UTCDateTime(timezone=True), nullable=True
|
|
36
|
+
)
|
|
37
|
+
accepted_by_id: Mapped[int | None] = mapped_column(
|
|
38
|
+
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
39
|
+
)
|
|
40
|
+
revoked_at: Mapped[datetime.datetime | None] = mapped_column(
|
|
41
|
+
UTCDateTime(timezone=True), nullable=True
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
created_by = relationship("User", foreign_keys=[created_by_id])
|
|
45
|
+
accepted_by = relationship("User", foreign_keys=[accepted_by_id])
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_expired(self) -> bool:
|
|
49
|
+
return self.expires_at is not None and self.expires_at <= time_utcnow()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_valid(self) -> bool:
|
|
53
|
+
return self.accepted_at is None and self.revoked_at is None and not self.is_expired
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def status(self) -> str:
|
|
57
|
+
if self.accepted_at is not None:
|
|
58
|
+
return "accepted"
|
|
59
|
+
if self.revoked_at is not None:
|
|
60
|
+
return "revoked"
|
|
61
|
+
if self.is_expired:
|
|
62
|
+
return "expired"
|
|
63
|
+
return "pending"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InviteQuota(BaseModel):
|
|
67
|
+
__tablename__ = "inviteonly_quotas"
|
|
68
|
+
|
|
69
|
+
user_id: Mapped[int] = mapped_column(
|
|
70
|
+
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
71
|
+
)
|
|
72
|
+
remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
73
|
+
|
|
74
|
+
user = relationship("User", foreign_keys=[user_id])
|
inviteonly/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{%- macro status_badge(status) -%}
|
|
2
|
+
{%- if status == "accepted" -%}
|
|
3
|
+
<span class="badge bg-success">{% trans %}accepted{% endtrans %}</span>
|
|
4
|
+
{%- elif status == "revoked" -%}
|
|
5
|
+
<span class="badge bg-danger">{% trans %}revoked{% endtrans %}</span>
|
|
6
|
+
{%- elif status == "expired" -%}
|
|
7
|
+
<span class="badge bg-secondary">{% trans %}expired{% endtrans %}</span>
|
|
8
|
+
{%- else -%}
|
|
9
|
+
<span class="badge bg-info">{% trans %}pending{% endtrans %}</span>
|
|
10
|
+
{%- endif -%}
|
|
11
|
+
{%- endmacro -%}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{%- if has_invites -%}
|
|
2
|
+
<ul class="navbar-nav me-2">
|
|
3
|
+
<li class="nav-item">
|
|
4
|
+
<a class="nav-link" href="{{ url_for('inviteonly.mine') }}">
|
|
5
|
+
<span class="fas fa-envelope-open-text fa-fw"></span> {% trans %}Invitations{% endtrans %}
|
|
6
|
+
{%- if remaining is not none -%}
|
|
7
|
+
<span class="badge bg-secondary">{{ remaining }}</span>
|
|
8
|
+
{%- endif -%}
|
|
9
|
+
</a>
|
|
10
|
+
</li>
|
|
11
|
+
</ul>
|
|
12
|
+
{%- endif -%}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{% set page_title = _("Invitations") %}
|
|
2
|
+
{% set active = "inviteonly.admin_invitations" %}
|
|
3
|
+
|
|
4
|
+
{% extends theme("management/management_layout.html") %}
|
|
5
|
+
|
|
6
|
+
{% block breadcrumb %}
|
|
7
|
+
<ol class="breadcrumb flaskbb-breadcrumb">
|
|
8
|
+
<li class="breadcrumb-item"><a href="{{ url_for('forum.index') }}">{% trans %}Forum{% endtrans %}</a></li>
|
|
9
|
+
<li class="breadcrumb-item"><a href="{{ url_for('management.overview') }}">{% trans %}Management{% endtrans %}</a></li>
|
|
10
|
+
<li class="breadcrumb-item active">{% trans %}Invitations{% endtrans %}</li>
|
|
11
|
+
</ol>
|
|
12
|
+
{% endblock %}
|
|
13
|
+
|
|
14
|
+
{% block management_content %}
|
|
15
|
+
{%- from theme('_macros/form.html') import render_field, action_confirm -%}
|
|
16
|
+
{%- from theme('_macros/pagination.html') import render_pagination -%}
|
|
17
|
+
{%- from theme('inviteonly/_macros.html') import status_badge -%}
|
|
18
|
+
|
|
19
|
+
<div class="col-md-12 settings-col">
|
|
20
|
+
<div class="card settings">
|
|
21
|
+
<div class="card-header settings-header">
|
|
22
|
+
<span class="fa fa-envelope-open-text"></span> {% trans %}Grant a user's quota{% endtrans %}
|
|
23
|
+
</div>
|
|
24
|
+
<div class="card-body settings-body">
|
|
25
|
+
<form class="row p-2" role="form" method="POST" action="{{ url_for('inviteonly.admin_grant_quota') }}">
|
|
26
|
+
{{ grant_form.hidden_tag() }}
|
|
27
|
+
{{ render_field(grant_form.username, div_class="mb-3 col-md-6") }}
|
|
28
|
+
{{ render_field(grant_form.remaining, div_class="mb-3 col-md-6") }}
|
|
29
|
+
{{ render_field(grant_form.unlimited) }}
|
|
30
|
+
{{ render_field(grant_form.submit) }}
|
|
31
|
+
</form>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="col-md-12 settings-col">
|
|
37
|
+
<div class="card settings">
|
|
38
|
+
<div class="card-header settings-header border-top">
|
|
39
|
+
<span class="fa fa-list"></span> {% trans %}All Invitations{% endtrans %}
|
|
40
|
+
</div>
|
|
41
|
+
<div class="card-body settings-body">
|
|
42
|
+
<div class="settings-content">
|
|
43
|
+
<div class="settings-meta row">
|
|
44
|
+
<div class="col-md-2 col-sm-2 col-3 ps-3 meta-item">{% trans %}Code{% endtrans %}</div>
|
|
45
|
+
<div class="col-md-2 col-sm-2 col-3 meta-item">{% trans %}Created by{% endtrans %}</div>
|
|
46
|
+
<div class="col-md-3 col-sm-3 d-none d-sm-block meta-item">{% trans %}Invited email{% endtrans %}</div>
|
|
47
|
+
<div class="col-md-2 col-sm-2 col-3 meta-item">{% trans %}Status{% endtrans %}</div>
|
|
48
|
+
<div class="col-md-2 col-sm-2 d-none d-sm-block meta-item">{% trans %}Created{% endtrans %}</div>
|
|
49
|
+
<div class="col-md-1 col-sm-1 col-3 meta-item">{% trans %}Actions{% endtrans %}</div>
|
|
50
|
+
</div>
|
|
51
|
+
{% for invite in invites.items %}
|
|
52
|
+
<div class="row settings-row hover">
|
|
53
|
+
<div class="col-md-2 col-sm-2 col-3 ps-3"><code>{{ invite.code }}</code></div>
|
|
54
|
+
<div class="col-md-2 col-sm-2 col-3">
|
|
55
|
+
{%- if invite.created_by -%}
|
|
56
|
+
<a href="{{ invite.created_by.url }}">{{ invite.created_by.username }}</a>
|
|
57
|
+
{%- else -%}
|
|
58
|
+
-
|
|
59
|
+
{%- endif -%}
|
|
60
|
+
</div>
|
|
61
|
+
<div class="col-md-3 col-sm-3 d-none d-sm-block">{{ invite.invited_email or "-" }}</div>
|
|
62
|
+
<div class="col-md-2 col-sm-2 col-3">{{ status_badge(invite.status) }}</div>
|
|
63
|
+
<div class="col-md-2 col-sm-2 d-none d-sm-block">{{ invite.created_at|format_date }}</div>
|
|
64
|
+
<div class="col-md-1 col-sm-1 col-3">
|
|
65
|
+
{% if invite.status == "pending" %}
|
|
66
|
+
{{ action_confirm(
|
|
67
|
+
"revoke-" ~ invite.id,
|
|
68
|
+
url_for("inviteonly.revoke_invite", invite_id=invite.id),
|
|
69
|
+
_("Revoke"),
|
|
70
|
+
icon="fas fa-ban text-danger"
|
|
71
|
+
) }}
|
|
72
|
+
{% endif %}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
{% else %}
|
|
76
|
+
<div class="row settings-row">
|
|
77
|
+
<div class="col-12 ps-3">{% trans %}No invitations yet.{% endtrans %}</div>
|
|
78
|
+
</div>
|
|
79
|
+
{% endfor %}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="card-footer settings-footer p-2">
|
|
84
|
+
{{ render_pagination(invites, url_for('inviteonly.admin_invitations')) }}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{% include theme('_partials/confirm_dialog.html') %}
|
|
90
|
+
{% endblock %}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{% set page_title = _("Enter Invitation Code") %}
|
|
2
|
+
{% set active_forum_nav=True %}
|
|
3
|
+
|
|
4
|
+
{% extends theme("layout.html") %}
|
|
5
|
+
{% block content %}
|
|
6
|
+
{% from theme("_macros/form.html") import horizontal_field %}
|
|
7
|
+
|
|
8
|
+
<div class="card page">
|
|
9
|
+
<div class="card-header page-header">
|
|
10
|
+
{% trans %}Have an invitation code?{% endtrans %}
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div class="card-body page-body">
|
|
14
|
+
<form role="form" method="POST">
|
|
15
|
+
{{ form.hidden_tag() }}
|
|
16
|
+
{{ horizontal_field(form.code) }}
|
|
17
|
+
{{ horizontal_field(form.submit, div_class="offset-3 col-4") }}
|
|
18
|
+
</form>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
{% endblock %}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{% set page_title = _("My Invitations") %}
|
|
2
|
+
|
|
3
|
+
{% extends theme("layout.html") %}
|
|
4
|
+
{% block content %}
|
|
5
|
+
{% from theme("_macros/form.html") import render_field, action_confirm %}
|
|
6
|
+
{% from theme("inviteonly/_macros.html") import status_badge %}
|
|
7
|
+
|
|
8
|
+
<div class="page-view">
|
|
9
|
+
<ol class="breadcrumb flaskbb-breadcrumb bg-light">
|
|
10
|
+
<li class="breadcrumb-item"><a href="{{ url_for('forum.index') }}">{% trans %}Forum{% endtrans %}</a></li>
|
|
11
|
+
<li class="breadcrumb-item"><a href="{{ url_for('user.profile', username=current_user.username) }}">{{ current_user.username }}</a></li>
|
|
12
|
+
<li class="breadcrumb-item active">{% trans %}My Invitations{% endtrans %}</li>
|
|
13
|
+
</ol>
|
|
14
|
+
|
|
15
|
+
<div class="card page">
|
|
16
|
+
<div class="card-header page-header">
|
|
17
|
+
<span class="fas fa-envelope-open-text"></span> {% trans %}My Invitations{% endtrans %}
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="card-body page-body">
|
|
21
|
+
<div class="p-3">
|
|
22
|
+
<p{% if not can_create %} class="mb-0"{% endif %}>
|
|
23
|
+
{% if remaining is none %}
|
|
24
|
+
{% trans %}You have unlimited invitations.{% endtrans %}
|
|
25
|
+
{% else %}
|
|
26
|
+
{% trans %}You have {% endtrans %}<strong>{{ remaining }}</strong>
|
|
27
|
+
{% trans %}invitation(s) left.{% endtrans %}
|
|
28
|
+
{% endif %}
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
{% if can_create %}
|
|
32
|
+
<form class="row g-2 align-items-end" role="form" method="POST">
|
|
33
|
+
{{ form.hidden_tag() }}
|
|
34
|
+
{{ render_field(form.email, div_class="col-md-6") }}
|
|
35
|
+
{{ render_field(form.submit, div_class="col-auto") }}
|
|
36
|
+
</form>
|
|
37
|
+
{% endif %}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="row m-0 page-meta">
|
|
41
|
+
<div class="col-md-2 col-sm-2 col-4 ps-3 meta-item">{% trans %}Code{% endtrans %}</div>
|
|
42
|
+
<div class="col-md-3 col-sm-3 d-none d-sm-block meta-item">{% trans %}Invited email{% endtrans %}</div>
|
|
43
|
+
<div class="col-md-2 col-sm-2 col-4 meta-item">{% trans %}Status{% endtrans %}</div>
|
|
44
|
+
<div class="col-md-2 col-sm-2 d-none d-sm-block meta-item">{% trans %}Created{% endtrans %}</div>
|
|
45
|
+
<div class="col-md-2 col-sm-2 d-none d-sm-block meta-item">{% trans %}Expires{% endtrans %}</div>
|
|
46
|
+
<div class="col-md-1 col-sm-1 col-4 meta-item">{% trans %}Actions{% endtrans %}</div>
|
|
47
|
+
</div>
|
|
48
|
+
{% for invite in invites %}
|
|
49
|
+
<div class="row page-row hover">
|
|
50
|
+
<div class="col-md-2 col-sm-2 col-4 ps-3"><code>{{ invite.code }}</code></div>
|
|
51
|
+
<div class="col-md-3 col-sm-3 d-none d-sm-block">{{ invite.invited_email or "-" }}</div>
|
|
52
|
+
<div class="col-md-2 col-sm-2 col-4">{{ status_badge(invite.status) }}</div>
|
|
53
|
+
<div class="col-md-2 col-sm-2 d-none d-sm-block">{{ invite.created_at|format_date }}</div>
|
|
54
|
+
<div class="col-md-2 col-sm-2 d-none d-sm-block">{{ invite.expires_at|format_date if invite.expires_at else "-" }}</div>
|
|
55
|
+
<div class="col-md-1 col-sm-1 col-4">
|
|
56
|
+
{% if invite.status == "pending" %}
|
|
57
|
+
{{ action_confirm(
|
|
58
|
+
"revoke-" ~ invite.id,
|
|
59
|
+
url_for("inviteonly.revoke_invite", invite_id=invite.id),
|
|
60
|
+
_("Revoke"),
|
|
61
|
+
icon="fas fa-ban text-danger"
|
|
62
|
+
) }}
|
|
63
|
+
{% endif %}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
{% else %}
|
|
67
|
+
<div class="row page-row">
|
|
68
|
+
<div class="col-12 ps-3">{% trans %}You haven't created any invitations yet.{% endtrans %}</div>
|
|
69
|
+
</div>
|
|
70
|
+
{% endfor %}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{% include theme('_partials/confirm_dialog.html') %}
|
|
76
|
+
{% endblock %}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{% set page_title = _("Register") %}
|
|
2
|
+
{% set active_forum_nav=True %}
|
|
3
|
+
|
|
4
|
+
{% extends theme("layout.html") %}
|
|
5
|
+
{% block content %}
|
|
6
|
+
{% from theme("_macros/form.html") import horizontal_field %}
|
|
7
|
+
|
|
8
|
+
<div class="card page">
|
|
9
|
+
<div class="card-header page-header">
|
|
10
|
+
{% trans %}Register with invitation{% endtrans %}
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div class="card-body page-body">
|
|
14
|
+
{% if invite.invited_email %}
|
|
15
|
+
<p class="text-muted">
|
|
16
|
+
{% trans %}This invitation is for {% endtrans %}<strong>{{ invite.invited_email }}</strong>.
|
|
17
|
+
</p>
|
|
18
|
+
{% endif %}
|
|
19
|
+
|
|
20
|
+
<form role="form" method="POST">
|
|
21
|
+
{{ form.hidden_tag() }}
|
|
22
|
+
{{ run_hook('flaskbb_tpl_form_registration_before', form=form) }}
|
|
23
|
+
{{ horizontal_field(form.username)}}
|
|
24
|
+
{{ horizontal_field(form.email)}}
|
|
25
|
+
{{ horizontal_field(form.password)}}
|
|
26
|
+
{{ horizontal_field(form.confirm_password)}}
|
|
27
|
+
|
|
28
|
+
{% if flaskbb_config["RECAPTCHA_ENABLED"] and 'recaptcha' in form %}
|
|
29
|
+
{{ horizontal_field(form.recaptcha) }}
|
|
30
|
+
{% endif %}
|
|
31
|
+
|
|
32
|
+
{{ horizontal_field(form.language) }}
|
|
33
|
+
{{ horizontal_field(form.accept_tos, div_class="offset-3 col-4")}}
|
|
34
|
+
{{ run_hook('flaskbb_tpl_form_registration_after', form=form) }}
|
|
35
|
+
{{ horizontal_field(form.submit, div_class="offset-3 col-4")}}
|
|
36
|
+
</form>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
{% endblock %}
|
inviteonly/utils.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
inviteonly.utils
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Helpers for generating invite codes, tracking invitation quotas, and
|
|
6
|
+
emailing invites.
|
|
7
|
+
|
|
8
|
+
:copyright: (c) 2026 by Peter Justin.
|
|
9
|
+
:license: BSD License, see LICENSE for more details.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import secrets
|
|
13
|
+
|
|
14
|
+
from flask_allows2 import Permission
|
|
15
|
+
from flaskbb.email import send_email
|
|
16
|
+
from flaskbb.settings import flaskbb_config
|
|
17
|
+
from flaskbb.user.models import User
|
|
18
|
+
from flaskbb.utils.requirements import IsAdmin
|
|
19
|
+
|
|
20
|
+
from .models import Invitation, InviteQuota
|
|
21
|
+
|
|
22
|
+
CODE_LENGTH = 10
|
|
23
|
+
|
|
24
|
+
# Setting defaults - also used as the SettingGroup's defaults in __init__.py,
|
|
25
|
+
# kept here (rather than there) so views.py can read config without a
|
|
26
|
+
# circular import between __init__.py and views.py.
|
|
27
|
+
DEFAULT_ENABLED = True
|
|
28
|
+
DEFAULT_QUOTA = 0
|
|
29
|
+
DEFAULT_EXPIRY_DAYS = 7
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_enabled() -> bool:
|
|
33
|
+
value = flaskbb_config["INVITEONLY_ENABLED"]
|
|
34
|
+
return DEFAULT_ENABLED if value is None else bool(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_quota() -> int:
|
|
38
|
+
value = flaskbb_config["INVITEONLY_DEFAULT_QUOTA"]
|
|
39
|
+
return DEFAULT_QUOTA if value is None else int(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def expiry_days() -> int:
|
|
43
|
+
value = flaskbb_config["INVITEONLY_EXPIRY_DAYS"]
|
|
44
|
+
return DEFAULT_EXPIRY_DAYS if value is None else int(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def generate_code() -> str:
|
|
48
|
+
while True:
|
|
49
|
+
code = secrets.token_urlsafe(CODE_LENGTH)[:CODE_LENGTH]
|
|
50
|
+
if Invitation.get(Invitation.code == code) is None:
|
|
51
|
+
return code
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def effective_remaining(user: User, default_quota: int) -> int | None:
|
|
55
|
+
"""The number of invitations ``user`` can still create, or ``None``
|
|
56
|
+
for unlimited. Falls back to ``default_quota`` if the user has no
|
|
57
|
+
quota row yet (nobody has granted or consumed a quota for them).
|
|
58
|
+
"""
|
|
59
|
+
quota = InviteQuota.get(InviteQuota.user_id == user.id)
|
|
60
|
+
if quota is None:
|
|
61
|
+
return default_quota
|
|
62
|
+
return quota.remaining
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def display_remaining(user: User, default_quota: int) -> int | None:
|
|
66
|
+
"""Remaining invitations to show in the UI - ``None`` both for a real
|
|
67
|
+
unlimited quota and for admins, who always bypass the quota check (see
|
|
68
|
+
``can_create_invite``) so showing their actual quota row would mislead.
|
|
69
|
+
"""
|
|
70
|
+
if Permission(IsAdmin, identity=user):
|
|
71
|
+
return None
|
|
72
|
+
return effective_remaining(user, default_quota)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def can_create_invite(user: User, default_quota: int) -> bool:
|
|
76
|
+
if Permission(IsAdmin, identity=user):
|
|
77
|
+
return True
|
|
78
|
+
remaining = effective_remaining(user, default_quota)
|
|
79
|
+
return remaining is None or remaining > 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def consume_quota(user: User, default_quota: int) -> None:
|
|
83
|
+
if Permission(IsAdmin, identity=user):
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
quota = InviteQuota.get(InviteQuota.user_id == user.id)
|
|
87
|
+
if quota is None:
|
|
88
|
+
quota = InviteQuota(user_id=user.id, remaining=default_quota)
|
|
89
|
+
|
|
90
|
+
if quota.remaining is not None:
|
|
91
|
+
quota.remaining = max(0, quota.remaining - 1)
|
|
92
|
+
|
|
93
|
+
quota.save()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def grant_quota(user: User, remaining: int | None) -> None:
|
|
97
|
+
quota = InviteQuota.get(InviteQuota.user_id == user.id)
|
|
98
|
+
if quota is None:
|
|
99
|
+
quota = InviteQuota(user_id=user.id, remaining=remaining)
|
|
100
|
+
else:
|
|
101
|
+
quota.remaining = remaining
|
|
102
|
+
quota.save()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
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
|
+
f"{invited_by.username} has invited you to join the forum.\n\n"
|
|
111
|
+
f"Follow this link to register: {link}\n"
|
|
112
|
+
),
|
|
113
|
+
html_body=(
|
|
114
|
+
f"<p>{invited_by.username} has invited you to join the forum.</p>"
|
|
115
|
+
f'<p><a href="{link}">{link}</a></p>'
|
|
116
|
+
),
|
|
117
|
+
)
|
inviteonly/views.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""
|
|
2
|
+
inviteonly.views
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Views for entering/redeeming an invitation code, managing your own
|
|
6
|
+
invitations, and (for admins) managing every invitation and user quota.
|
|
7
|
+
|
|
8
|
+
:copyright: (c) 2026 by Peter Justin.
|
|
9
|
+
:license: BSD License, see LICENSE for more details.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
|
|
15
|
+
from flask import abort, Blueprint, flash, redirect, request, url_for
|
|
16
|
+
from flask.views import MethodView
|
|
17
|
+
from flask_allows2 import Permission
|
|
18
|
+
from flask_babelplus import gettext as _
|
|
19
|
+
from flask_login import current_user, login_required
|
|
20
|
+
from flaskbb.auth.forms import RegisterForm
|
|
21
|
+
from flaskbb.auth.services.factories import registration_service_factory
|
|
22
|
+
from flaskbb.core.auth.registration import UserRegistrationInfo
|
|
23
|
+
from flaskbb.core.exceptions import PersistenceError, StopValidation
|
|
24
|
+
from flaskbb.extensions import allows, db, pluggy
|
|
25
|
+
from flaskbb.settings import flaskbb_config
|
|
26
|
+
from flaskbb.user.models import User
|
|
27
|
+
from flaskbb.utils.helpers import (
|
|
28
|
+
anonymous_required,
|
|
29
|
+
FlashAndRedirect,
|
|
30
|
+
get_available_languages,
|
|
31
|
+
real,
|
|
32
|
+
redirect_or_next,
|
|
33
|
+
register_view,
|
|
34
|
+
render_template,
|
|
35
|
+
time_utcnow,
|
|
36
|
+
)
|
|
37
|
+
from flaskbb.utils.requirements import IsAdmin
|
|
38
|
+
from sqlalchemy import func, select
|
|
39
|
+
|
|
40
|
+
from .forms import (
|
|
41
|
+
AdminGrantQuotaForm,
|
|
42
|
+
AdminQuotaForm,
|
|
43
|
+
CreateInviteForm,
|
|
44
|
+
InviteCodeForm,
|
|
45
|
+
RevokeInviteForm,
|
|
46
|
+
)
|
|
47
|
+
from .models import Invitation
|
|
48
|
+
from .utils import (
|
|
49
|
+
can_create_invite,
|
|
50
|
+
consume_quota,
|
|
51
|
+
default_quota,
|
|
52
|
+
display_remaining,
|
|
53
|
+
expiry_days,
|
|
54
|
+
generate_code,
|
|
55
|
+
grant_quota,
|
|
56
|
+
is_enabled,
|
|
57
|
+
send_invite_email,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
inviteonly_bp = Blueprint(
|
|
63
|
+
"inviteonly", __name__, template_folder="templates", static_folder="static"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _expires_at(days: int):
|
|
68
|
+
return None if days <= 0 else time_utcnow() + timedelta(days=days)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EnterInviteCode(MethodView):
|
|
72
|
+
decorators = [anonymous_required]
|
|
73
|
+
|
|
74
|
+
def get(self):
|
|
75
|
+
return render_template("inviteonly/enter_code.html", form=InviteCodeForm())
|
|
76
|
+
|
|
77
|
+
def post(self):
|
|
78
|
+
form = InviteCodeForm()
|
|
79
|
+
if form.validate_on_submit():
|
|
80
|
+
return redirect(url_for("inviteonly.register_with_invite", code=form.code.data.strip()))
|
|
81
|
+
return render_template("inviteonly/enter_code.html", form=form)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RegisterWithInvite(MethodView):
|
|
85
|
+
decorators = [anonymous_required]
|
|
86
|
+
|
|
87
|
+
def _invite(self, code: str) -> Invitation | None:
|
|
88
|
+
if not is_enabled():
|
|
89
|
+
return None
|
|
90
|
+
invite = Invitation.get(Invitation.code == code)
|
|
91
|
+
if invite is None or not invite.is_valid:
|
|
92
|
+
return None
|
|
93
|
+
return invite
|
|
94
|
+
|
|
95
|
+
def form(self, invite: Invitation):
|
|
96
|
+
pluggy.hook.flaskbb_form_registration(form=RegisterForm)
|
|
97
|
+
form = RegisterForm()
|
|
98
|
+
form.language.choices = get_available_languages() # pyright: ignore
|
|
99
|
+
form.language.default = flaskbb_config["DEFAULT_LANGUAGE"]
|
|
100
|
+
form.process(request.form) # needed because a default is overriden
|
|
101
|
+
|
|
102
|
+
if invite.invited_email:
|
|
103
|
+
# Locked to the invited address - reassigned after processing so
|
|
104
|
+
# a tampered client-side submission can't register under a
|
|
105
|
+
# different email than the one the invite was issued to.
|
|
106
|
+
form.email.data = invite.invited_email
|
|
107
|
+
form.email.render_kw = {"readonly": True}
|
|
108
|
+
|
|
109
|
+
return form
|
|
110
|
+
|
|
111
|
+
def get(self, code: str):
|
|
112
|
+
invite = self._invite(code)
|
|
113
|
+
if invite is None:
|
|
114
|
+
flash(_("This invitation link is invalid or has expired."), "danger")
|
|
115
|
+
return redirect(url_for("forum.index"))
|
|
116
|
+
return render_template("inviteonly/register.html", form=self.form(invite), invite=invite)
|
|
117
|
+
|
|
118
|
+
def post(self, code: str):
|
|
119
|
+
invite = self._invite(code)
|
|
120
|
+
if invite is None:
|
|
121
|
+
flash(_("This invitation link is invalid or has expired."), "danger")
|
|
122
|
+
return redirect(url_for("forum.index"))
|
|
123
|
+
|
|
124
|
+
form = self.form(invite)
|
|
125
|
+
if form.validate_on_submit():
|
|
126
|
+
registration_info = UserRegistrationInfo(
|
|
127
|
+
username=form.username.data,
|
|
128
|
+
password=form.password.data,
|
|
129
|
+
group=4,
|
|
130
|
+
email=form.email.data,
|
|
131
|
+
language=form.language.data,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
service = registration_service_factory()
|
|
135
|
+
try:
|
|
136
|
+
user = service.register(registration_info)
|
|
137
|
+
except StopValidation as e:
|
|
138
|
+
form.populate_errors(e.reasons)
|
|
139
|
+
return render_template("inviteonly/register.html", form=form, invite=invite)
|
|
140
|
+
except PersistenceError:
|
|
141
|
+
logger.exception("Database error while persisting user")
|
|
142
|
+
flash(
|
|
143
|
+
_("Could not process registration due to an unrecoverable error"),
|
|
144
|
+
"danger",
|
|
145
|
+
)
|
|
146
|
+
return render_template("inviteonly/register.html", form=form, invite=invite)
|
|
147
|
+
|
|
148
|
+
invite.accepted_at = time_utcnow()
|
|
149
|
+
invite.accepted_by_id = user.id
|
|
150
|
+
invite.save()
|
|
151
|
+
|
|
152
|
+
return redirect_or_next(url_for("forum.index"))
|
|
153
|
+
|
|
154
|
+
return render_template("inviteonly/register.html", form=form, invite=invite)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class MyInvitations(MethodView):
|
|
158
|
+
decorators = [login_required]
|
|
159
|
+
|
|
160
|
+
def _render(self, form):
|
|
161
|
+
user = real(current_user)
|
|
162
|
+
invites = Invitation.get_all(Invitation.created_by_id == user.id)
|
|
163
|
+
invites.sort(key=lambda i: i.created_at, reverse=True)
|
|
164
|
+
|
|
165
|
+
return render_template(
|
|
166
|
+
"inviteonly/mine.html",
|
|
167
|
+
invites=invites,
|
|
168
|
+
remaining=display_remaining(user, default_quota()),
|
|
169
|
+
can_create=can_create_invite(user, default_quota()),
|
|
170
|
+
form=form,
|
|
171
|
+
revoke_form=RevokeInviteForm(),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def get(self):
|
|
175
|
+
return self._render(CreateInviteForm())
|
|
176
|
+
|
|
177
|
+
def post(self):
|
|
178
|
+
user = real(current_user)
|
|
179
|
+
form = CreateInviteForm()
|
|
180
|
+
|
|
181
|
+
if not can_create_invite(user, default_quota()):
|
|
182
|
+
flash(_("You have no invitations left."), "danger")
|
|
183
|
+
return redirect(url_for("inviteonly.mine"))
|
|
184
|
+
|
|
185
|
+
if not form.validate_on_submit():
|
|
186
|
+
return self._render(form)
|
|
187
|
+
|
|
188
|
+
invite = Invitation(
|
|
189
|
+
code=generate_code(),
|
|
190
|
+
created_by_id=user.id,
|
|
191
|
+
invited_email=form.email.data or None,
|
|
192
|
+
expires_at=_expires_at(expiry_days()),
|
|
193
|
+
)
|
|
194
|
+
invite.save()
|
|
195
|
+
consume_quota(user, default_quota())
|
|
196
|
+
|
|
197
|
+
link = url_for("inviteonly.register_with_invite", code=invite.code, _external=True)
|
|
198
|
+
if form.email.data:
|
|
199
|
+
send_invite_email(form.email.data, link, user)
|
|
200
|
+
flash(
|
|
201
|
+
_(
|
|
202
|
+
"Invitation sent to %(email)s: %(link)s",
|
|
203
|
+
email=form.email.data,
|
|
204
|
+
link=link,
|
|
205
|
+
),
|
|
206
|
+
"success",
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
flash(_("Invitation created: %(link)s", link=link), "success")
|
|
210
|
+
|
|
211
|
+
return redirect(url_for("inviteonly.mine"))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class RevokeInvite(MethodView):
|
|
215
|
+
decorators = [login_required]
|
|
216
|
+
|
|
217
|
+
def post(self, invite_id: int):
|
|
218
|
+
invite = Invitation.get_or_404(Invitation.id == invite_id)
|
|
219
|
+
user = real(current_user)
|
|
220
|
+
|
|
221
|
+
is_owner = invite.created_by_id == user.id
|
|
222
|
+
if not is_owner and not Permission(IsAdmin, identity=user):
|
|
223
|
+
abort(404)
|
|
224
|
+
|
|
225
|
+
if invite.status != "pending":
|
|
226
|
+
flash(_("That invitation can no longer be revoked."), "info")
|
|
227
|
+
else:
|
|
228
|
+
invite.revoked_at = time_utcnow()
|
|
229
|
+
invite.save()
|
|
230
|
+
flash(_("Invitation revoked."), "success")
|
|
231
|
+
|
|
232
|
+
return redirect_or_next(url_for("inviteonly.mine"))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class AdminInvitations(MethodView):
|
|
236
|
+
decorators = [
|
|
237
|
+
allows.requires(
|
|
238
|
+
IsAdmin,
|
|
239
|
+
on_fail=FlashAndRedirect(
|
|
240
|
+
message=_("You are not allowed to manage invitations"),
|
|
241
|
+
level="danger",
|
|
242
|
+
endpoint="forum.index",
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
def get(self):
|
|
248
|
+
page = request.args.get("page", 1, type=int)
|
|
249
|
+
invites = db.paginate(
|
|
250
|
+
select(Invitation).order_by(Invitation.created_at.desc()),
|
|
251
|
+
page=page,
|
|
252
|
+
per_page=flaskbb_config["USERS_PER_PAGE"],
|
|
253
|
+
error_out=False,
|
|
254
|
+
)
|
|
255
|
+
return render_template(
|
|
256
|
+
"inviteonly/admin.html",
|
|
257
|
+
invites=invites,
|
|
258
|
+
revoke_form=RevokeInviteForm(),
|
|
259
|
+
quota_form=AdminQuotaForm(),
|
|
260
|
+
grant_form=AdminGrantQuotaForm(),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class AdminSetQuota(MethodView):
|
|
265
|
+
decorators = [
|
|
266
|
+
allows.requires(
|
|
267
|
+
IsAdmin,
|
|
268
|
+
on_fail=FlashAndRedirect(
|
|
269
|
+
message=_("You are not allowed to manage invitations"),
|
|
270
|
+
level="danger",
|
|
271
|
+
endpoint="forum.index",
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
def post(self, user_id: int):
|
|
277
|
+
target = User.get_or_404(User.id == user_id)
|
|
278
|
+
form = AdminQuotaForm()
|
|
279
|
+
|
|
280
|
+
if form.validate_on_submit():
|
|
281
|
+
remaining = None if form.unlimited.data else (form.remaining.data or 0)
|
|
282
|
+
grant_quota(target, remaining)
|
|
283
|
+
flash(
|
|
284
|
+
_(
|
|
285
|
+
"Updated invitation quota for %(username)s.",
|
|
286
|
+
username=target.username,
|
|
287
|
+
),
|
|
288
|
+
"success",
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
flash(_("Could not update the quota."), "danger")
|
|
292
|
+
|
|
293
|
+
return redirect_or_next(url_for("inviteonly.admin_invitations"))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class AdminGrantQuota(MethodView):
|
|
297
|
+
decorators = [
|
|
298
|
+
allows.requires(
|
|
299
|
+
IsAdmin,
|
|
300
|
+
on_fail=FlashAndRedirect(
|
|
301
|
+
message=_("You are not allowed to manage invitations"),
|
|
302
|
+
level="danger",
|
|
303
|
+
endpoint="forum.index",
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
def post(self):
|
|
309
|
+
form = AdminGrantQuotaForm()
|
|
310
|
+
|
|
311
|
+
if form.validate_on_submit():
|
|
312
|
+
target = User.get(func.lower(User.username) == form.username.data.lower())
|
|
313
|
+
if target is None:
|
|
314
|
+
flash(
|
|
315
|
+
_("No user named %(username)s.", username=form.username.data),
|
|
316
|
+
"danger",
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
remaining = None if form.unlimited.data else (form.remaining.data or 0)
|
|
320
|
+
grant_quota(target, remaining)
|
|
321
|
+
flash(
|
|
322
|
+
_(
|
|
323
|
+
"Updated invitation quota for %(username)s.",
|
|
324
|
+
username=target.username,
|
|
325
|
+
),
|
|
326
|
+
"success",
|
|
327
|
+
)
|
|
328
|
+
else:
|
|
329
|
+
flash(_("Could not update the quota."), "danger")
|
|
330
|
+
|
|
331
|
+
return redirect_or_next(url_for("inviteonly.admin_invitations"))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
register_view(inviteonly_bp, routes=[""], view_func=EnterInviteCode.as_view("enter_code"))
|
|
335
|
+
register_view(
|
|
336
|
+
inviteonly_bp,
|
|
337
|
+
routes=["/mine"],
|
|
338
|
+
view_func=MyInvitations.as_view("mine"),
|
|
339
|
+
)
|
|
340
|
+
register_view(
|
|
341
|
+
inviteonly_bp,
|
|
342
|
+
routes=["/admin"],
|
|
343
|
+
view_func=AdminInvitations.as_view("admin_invitations"),
|
|
344
|
+
)
|
|
345
|
+
register_view(
|
|
346
|
+
inviteonly_bp,
|
|
347
|
+
routes=["/admin/quota/<int:user_id>"],
|
|
348
|
+
view_func=AdminSetQuota.as_view("admin_set_quota"),
|
|
349
|
+
)
|
|
350
|
+
register_view(
|
|
351
|
+
inviteonly_bp,
|
|
352
|
+
routes=["/admin/quota"],
|
|
353
|
+
view_func=AdminGrantQuota.as_view("admin_grant_quota"),
|
|
354
|
+
)
|
|
355
|
+
register_view(
|
|
356
|
+
inviteonly_bp,
|
|
357
|
+
routes=["/<int:invite_id>/revoke"],
|
|
358
|
+
view_func=RevokeInvite.as_view("revoke_invite"),
|
|
359
|
+
)
|
|
360
|
+
register_view(
|
|
361
|
+
inviteonly_bp,
|
|
362
|
+
routes=["/<code>"],
|
|
363
|
+
view_func=RegisterWithInvite.as_view("register_with_invite"),
|
|
364
|
+
)
|