flaskbb-plugin-inviteonly 1.0.0__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.
- flaskbb_plugin_inviteonly-1.0.0/.gitignore +44 -0
- flaskbb_plugin_inviteonly-1.0.0/LICENSE +31 -0
- flaskbb_plugin_inviteonly-1.0.0/PKG-INFO +91 -0
- flaskbb_plugin_inviteonly-1.0.0/README.md +63 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/__init__.py +118 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/forms.py +54 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/migrations/202607301400_c3e7a1f9b8d2_add_inviteonly.py +84 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/migrations/__init__.py +0 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/models.py +74 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/py.typed +0 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/_macros.html +11 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/_navlink.html +12 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/admin.html +90 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/enter_code.html +22 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/mine.html +76 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/register.html +40 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/utils.py +117 -0
- flaskbb_plugin_inviteonly-1.0.0/inviteonly/views.py +364 -0
- flaskbb_plugin_inviteonly-1.0.0/pyproject.toml +256 -0
- flaskbb_plugin_inviteonly-1.0.0/tests/conftest.py +14 -0
- flaskbb_plugin_inviteonly-1.0.0/tests/test_inviteonly_models.py +57 -0
- flaskbb_plugin_inviteonly-1.0.0/tests/test_inviteonly_utils.py +83 -0
- flaskbb_plugin_inviteonly-1.0.0/tests/test_inviteonly_views.py +162 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
.Python
|
|
8
|
+
build/
|
|
9
|
+
develop-eggs/
|
|
10
|
+
dist/
|
|
11
|
+
downloads/
|
|
12
|
+
eggs/
|
|
13
|
+
.eggs/
|
|
14
|
+
lib/
|
|
15
|
+
lib64/
|
|
16
|
+
parts/
|
|
17
|
+
sdist/
|
|
18
|
+
var/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Unit test / coverage reports
|
|
24
|
+
htmlcov/
|
|
25
|
+
.tox/
|
|
26
|
+
.coverage
|
|
27
|
+
.coverage.*
|
|
28
|
+
.cache
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
.ruff_cache/
|
|
31
|
+
nosetests.xml
|
|
32
|
+
coverage.xml
|
|
33
|
+
*,cover
|
|
34
|
+
.hypothesis/
|
|
35
|
+
|
|
36
|
+
# Translations
|
|
37
|
+
*.mo
|
|
38
|
+
*.pot
|
|
39
|
+
|
|
40
|
+
*.log
|
|
41
|
+
|
|
42
|
+
.vscode
|
|
43
|
+
.venv/
|
|
44
|
+
.mypy_cache/
|
|
@@ -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.
|
|
@@ -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,63 @@
|
|
|
1
|
+
Invite-only
|
|
2
|
+
===========
|
|
3
|
+
|
|
4
|
+
Invite-only registration for FlaskBB. Lets an admin, or a member with an
|
|
5
|
+
invitation quota, generate an invitation code/link (optionally emailed)
|
|
6
|
+
that lets someone register even while registration is otherwise closed.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
Installation
|
|
10
|
+
------------
|
|
11
|
+
|
|
12
|
+
Install *flaskbb-plugin-inviteonly* with
|
|
13
|
+
``pip install flaskbb-plugin-inviteonly``
|
|
14
|
+
|
|
15
|
+
For local development against a FlaskBB checkout, see "Developing a new,
|
|
16
|
+
standalone plugin" in FlaskBB's own
|
|
17
|
+
[plugin development docs](https://flaskbb.readthedocs.io/en/latest/development/plugin/developing.html) -
|
|
18
|
+
in short, from FlaskBB's root folder:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
uv pip install -e ../flaskbb-plugin-inviteonly
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
Usage
|
|
26
|
+
-----
|
|
27
|
+
|
|
28
|
+
Any logged-in member with a nonzero (or unlimited) invitation quota can
|
|
29
|
+
create invitations from "My Invitations" (linked in the user menu). Each
|
|
30
|
+
invitation is a one-time code/link; an email address can optionally be
|
|
31
|
+
given to send it directly, otherwise the code/link is shown to copy and
|
|
32
|
+
share manually.
|
|
33
|
+
|
|
34
|
+
The recipient registers either by following the link
|
|
35
|
+
(`/invite/<code>`) or by entering the code on the "Have an invitation
|
|
36
|
+
code?" page (`/invite`) - both work even while FlaskBB's own
|
|
37
|
+
registration is closed (`REGISTRATION_ENABLED` off).
|
|
38
|
+
|
|
39
|
+
Admins manage every invitation and grant/adjust any user's quota from
|
|
40
|
+
the admin panel's "Invitations" page.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Settings
|
|
44
|
+
--------
|
|
45
|
+
|
|
46
|
+
*Invite-only Settings*, under the admin panel's Plugin Settings, has
|
|
47
|
+
three options:
|
|
48
|
+
|
|
49
|
+
* **Enable invite-only registration** - master toggle for the invitation
|
|
50
|
+
routes (default: on).
|
|
51
|
+
* **Default invitation quota** - how many invitations a user can create
|
|
52
|
+
before an admin has granted them a quota (default: 0, meaning
|
|
53
|
+
invitations are admin-only until granted).
|
|
54
|
+
* **Invitation expiry (days)** - how long an invitation stays valid
|
|
55
|
+
(default: 7, 0 means it never expires).
|
|
56
|
+
|
|
57
|
+
Admins always bypass the quota check and can create invitations
|
|
58
|
+
regardless of their own quota.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
License
|
|
62
|
+
-------
|
|
63
|
+
This project is licensed under the terms of the [BSD License](/LICENSE).
|
|
@@ -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")]
|
|
@@ -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
|
+
"""
|
flaskbb_plugin_inviteonly-1.0.0/inviteonly/migrations/202607301400_c3e7a1f9b8d2_add_inviteonly.py
ADDED
|
@@ -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
|
|
@@ -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])
|
|
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 %}
|