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.
Files changed (23) hide show
  1. flaskbb_plugin_inviteonly-1.0.0/.gitignore +44 -0
  2. flaskbb_plugin_inviteonly-1.0.0/LICENSE +31 -0
  3. flaskbb_plugin_inviteonly-1.0.0/PKG-INFO +91 -0
  4. flaskbb_plugin_inviteonly-1.0.0/README.md +63 -0
  5. flaskbb_plugin_inviteonly-1.0.0/inviteonly/__init__.py +118 -0
  6. flaskbb_plugin_inviteonly-1.0.0/inviteonly/forms.py +54 -0
  7. flaskbb_plugin_inviteonly-1.0.0/inviteonly/migrations/202607301400_c3e7a1f9b8d2_add_inviteonly.py +84 -0
  8. flaskbb_plugin_inviteonly-1.0.0/inviteonly/migrations/__init__.py +0 -0
  9. flaskbb_plugin_inviteonly-1.0.0/inviteonly/models.py +74 -0
  10. flaskbb_plugin_inviteonly-1.0.0/inviteonly/py.typed +0 -0
  11. flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/_macros.html +11 -0
  12. flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/_navlink.html +12 -0
  13. flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/admin.html +90 -0
  14. flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/enter_code.html +22 -0
  15. flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/mine.html +76 -0
  16. flaskbb_plugin_inviteonly-1.0.0/inviteonly/templates/inviteonly/register.html +40 -0
  17. flaskbb_plugin_inviteonly-1.0.0/inviteonly/utils.py +117 -0
  18. flaskbb_plugin_inviteonly-1.0.0/inviteonly/views.py +364 -0
  19. flaskbb_plugin_inviteonly-1.0.0/pyproject.toml +256 -0
  20. flaskbb_plugin_inviteonly-1.0.0/tests/conftest.py +14 -0
  21. flaskbb_plugin_inviteonly-1.0.0/tests/test_inviteonly_models.py +57 -0
  22. flaskbb_plugin_inviteonly-1.0.0/tests/test_inviteonly_utils.py +83 -0
  23. 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
+ """
@@ -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")
@@ -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 %}