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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [flaskbb_plugins]
2
+ inviteonly = inviteonly
@@ -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
+ )