Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions USER_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
### 1.1 注册账户

1. 在登录页面点击「立即注册」进入注册页面。
<br><img src="static/img/favicon.svg">
2. 完成人机验证(图片验证码 / 滑动验证)。
3. 填写邮箱地址,点击发送验证码,查收邮件并输入 6 位数字验证码。
4. 填写用户名、密码等必要信息,提交注册。
Expand Down
53 changes: 53 additions & 0 deletions apps/accounts/migrations/0007_add_registrationlink_max_uses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


def fix_existing_used_links(apps, schema_editor):
RegistrationLink = apps.get_model('accounts', 'RegistrationLink')
RegistrationLink.objects.filter(used=True).update(used_count=1)


class Migration(migrations.Migration):

dependencies = [
("accounts", "0006_registrationlink"),
]

operations = [
migrations.AddField(
model_name="registrationlink",
name="max_uses",
field=models.IntegerField(
default=1,
help_text="设置为0表示不限制使用次数",
verbose_name="最大使用次数",
),
),
migrations.AddField(
model_name="registrationlink",
name="used_count",
field=models.IntegerField(default=0, verbose_name="已使用次数"),
),
migrations.AlterField(
model_name="registrationlink",
name="used_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="最后使用时间"
),
),
migrations.AlterField(
model_name="registrationlink",
name="used_by",
field=models.ForeignKey(
blank=True,
help_text="最后使用此链接注册的用户",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="used_registration_link",
to=settings.AUTH_USER_MODEL,
verbose_name="最后使用者",
),
),
migrations.RunPython(fix_existing_used_links, migrations.RunPython.noop),
]
25 changes: 25 additions & 0 deletions apps/accounts/migrations/0008_user_site_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 4.2.27 on 2026-05-31 13:27

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("dashboard", "0014_sitegroup_sitegrouphostname_and_more"),
("accounts", "0007_add_registrationlink_max_uses"),
]

operations = [
migrations.AddField(
model_name="user",
name="site_groups",
field=models.ManyToManyField(
blank=True,
help_text="用户所属的站点组,决定用户可见的数据范围",
related_name="members",
to="dashboard.sitegroup",
verbose_name="所属站点组",
),
),
]
66 changes: 60 additions & 6 deletions apps/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ class User(AbstractUser):
help_text=_('用户信息最后更新时间')
)

site_groups = models.ManyToManyField(
'dashboard.SiteGroup',
blank=True,
related_name='members',
verbose_name=_('所属站点组'),
help_text=_('用户所属的站点组,决定用户可见的数据范围'),
)

class Meta:
verbose_name = _('用户')
verbose_name_plural = verbose_name
Expand All @@ -75,6 +83,19 @@ def get_full_name(self):
full_name = super().get_full_name()
return full_name if full_name else self.username

def is_site_group_admin(self, site_group=None):
if self.is_superuser:
return True
if site_group is None:
return False
return site_group.admins.filter(pk=self.pk).exists()

def get_adminable_site_groups(self):
if self.is_superuser:
from apps.dashboard.models import SiteGroup
return SiteGroup.objects.all()
return self.admin_site_groups.all()

def sync_staff_status(self):
if self.is_superuser:
if not self.is_staff:
Expand Down Expand Up @@ -333,6 +354,15 @@ class RegistrationLink(models.Model):
auto_now_add=True,
verbose_name=_('创建时间')
)
max_uses = models.IntegerField(
default=1,
verbose_name=_('最大使用次数'),
help_text=_('设置为0表示不限制使用次数')
)
used_count = models.IntegerField(
default=0,
verbose_name=_('已使用次数')
)
used = models.BooleanField(
default=False,
verbose_name=_('已使用'),
Expand All @@ -344,13 +374,13 @@ class RegistrationLink(models.Model):
null=True,
blank=True,
related_name='used_registration_link',
verbose_name=_('使用者'),
help_text=_('使用此链接注册的用户')
verbose_name=_('最后使用者'),
help_text=_('最后使用此链接注册的用户')
)
used_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_('使用时间')
verbose_name=_('最后使用时间')
)
expires_at = models.DateTimeField(
null=True,
Expand All @@ -371,15 +401,39 @@ class Meta:
ordering = ['-created_at']

def __str__(self):
status = '已使用' if self.used else '未使用'
return f'注册链接({self.group.name}) - {status}'
if self.max_uses == 0:
usage = f'已用{self.used_count}次/不限'
else:
usage = f'已用{self.used_count}/{self.max_uses}次'
return f'注册链接({self.group.name}) - {usage}'

@property
def is_expired(self):
if self.expires_at is None:
return False
return timezone.now() > self.expires_at

@property
def is_exhausted(self):
if self.max_uses == 0:
return False
return self.used_count >= self.max_uses

@property
def is_valid(self):
return not self.used and not self.is_expired
return not self.is_exhausted and not self.is_expired

def increment_usage(self, user):
from django.db.models import F
RegistrationLink.objects.filter(pk=self.pk).update(
used_count=F('used_count') + 1,
used_by=user,
used_at=timezone.now(),
)
self.refresh_from_db()
if self.max_uses > 0 and self.used_count >= self.max_uses:
RegistrationLink.objects.filter(pk=self.pk).update(used=True)
self.used = True
else:
RegistrationLink.objects.filter(pk=self.pk).update(used=False)
self.used = False
39 changes: 38 additions & 1 deletion apps/accounts/provider_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,42 @@ def wrapper(request, *args, **kwargs):
return decorator


def is_site_group_admin(user, site_group=None):
if not user.is_authenticated:
return False
if user.is_superuser:
return True
if site_group is None:
return False
return site_group.admins.filter(pk=user.pk).exists()


def site_group_admin_required(view_func=None, redirect_field_name=REDIRECT_FIELD_NAME,
login_url=None):
def decorator(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
if not request.user.is_authenticated:
from django.contrib.auth.views import redirect_to_login
path = request.get_full_path()
return redirect_to_login(
path,
login_url=login_url,
redirect_field_name=redirect_field_name,
)
site_group = getattr(request, 'site_group', None)
if not (request.user.is_superuser or is_site_group_admin(request.user, site_group)):
return HttpResponseForbidden(
'您没有站点组管理员权限,无法访问此页面。'
)
return func(request, *args, **kwargs)
return wrapper

if view_func:
return decorator(view_func)
return decorator


def admin_required(view_func=None, redirect_field_name=REDIRECT_FIELD_NAME,
login_url=None):
"""
Expand Down Expand Up @@ -158,7 +194,8 @@ def wrapper(request, *args, **kwargs):
redirect_field_name=redirect_field_name,
)
user = request.user
if not (user.is_staff or is_provider(user)):
site_group = getattr(request, 'site_group', None)
if not (user.is_staff or is_provider(user) or is_site_group_admin(user, site_group)):
return HttpResponseForbidden(
'您没有后台访问权限。'
)
Expand Down
43 changes: 27 additions & 16 deletions apps/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,14 +320,29 @@ def _gen_code(length=6):
@rate_limit.email_code_rate_limit
def send_register_email_code(request):
"""Send a one-time code to the supplied email for registration."""
# 检查是否启用了注册功能
from apps.dashboard.models import SystemConfig
cfg = SystemConfig.get_config()
if not cfg.enable_registration:
return JsonResponse(
{'status': 'error', 'message': '注册功能已被管理员禁用'},
status=400
)
reglink_token = request.POST.get('reglink_token', '').strip()

if reglink_token:
try:
reglink = RegistrationLink.objects.get(token=reglink_token)
if not reglink.is_valid:
return JsonResponse(
{'status': 'error', 'message': '邀请链接无效或已失效'},
status=400
)
except RegistrationLink.DoesNotExist:
return JsonResponse(
{'status': 'error', 'message': '邀请链接不存在'},
status=400
)
else:
from apps.dashboard.models import SystemConfig
cfg = SystemConfig.get_config()
if not cfg.enable_registration:
return JsonResponse(
{'status': 'error', 'message': '注册功能已被管理员禁用'},
status=400
)

email = request.POST.get('email')

Expand Down Expand Up @@ -452,7 +467,7 @@ def send_register_email_code(request):

from .email_service import EmailService
try:
email_service = EmailService.from_system_config(cfg)
email_service = EmailService.from_system_config(config)
email_service.send_email(
to_emails=[email],
subject=subject,
Expand Down Expand Up @@ -489,8 +504,8 @@ def dispatch(self, request, *args, **kwargs):
messages.error(request, '注册链接不存在')
return redirect('accounts:register')

if self.reglink.used:
messages.error(request, '此注册链接已被使用')
if self.reglink.is_exhausted:
messages.error(request, '此注册链接可用次数已用完')
return redirect('accounts:register')

if self.reglink.is_expired:
Expand Down Expand Up @@ -531,11 +546,7 @@ def form_valid(self, form):
user.groups.set([self.reglink.group])
user.sync_staff_status()

from django.utils import timezone
self.reglink.used = True
self.reglink.used_by = user
self.reglink.used_at = timezone.now()
self.reglink.save(update_fields=['used', 'used_by', 'used_at'])
self.reglink.increment_usage(user)

messages.success(
self.request,
Expand Down
Loading
Loading