From b9f8deb807bd130a4f89133c9b6d4f90dd78744b Mon Sep 17 00:00:00 2001 From: bestony Date: Thu, 25 Dec 2025 17:29:39 +0800 Subject: [PATCH 01/10] feat: add basic label data --- config/settings.py | 1 + labels/__init__.py | 1 + labels/admin.py | 389 ++++++++++++++++++++++++++++++ labels/apps.py | 7 + labels/migrations/0001_initial.py | 94 ++++++++ labels/migrations/__init__.py | 0 labels/models.py | 250 +++++++++++++++++++ 7 files changed, 742 insertions(+) create mode 100644 labels/__init__.py create mode 100644 labels/admin.py create mode 100644 labels/apps.py create mode 100644 labels/migrations/0001_initial.py create mode 100644 labels/migrations/__init__.py create mode 100644 labels/models.py diff --git a/config/settings.py b/config/settings.py index 1d27f22..f110c8a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -129,6 +129,7 @@ "shop", "chdb", "messages.apps.SiteMessagesConfig", + "labels", ] _BASE_MIDDLEWARE = [ diff --git a/labels/__init__.py b/labels/__init__.py new file mode 100644 index 0000000..ca6d8c0 --- /dev/null +++ b/labels/__init__.py @@ -0,0 +1 @@ +default_app_config = "labels.apps.LabelsConfig" diff --git a/labels/admin.py b/labels/admin.py new file mode 100644 index 0000000..e2e0abb --- /dev/null +++ b/labels/admin.py @@ -0,0 +1,389 @@ +import json + +from django.contrib import admin +from django.http import HttpResponse +from django.utils.html import format_html + +from .models import Label, LabelPermission, LabelPermissionLog + + +class LabelPermissionInline(admin.TabularInline): + """标签权限内联编辑""" + + model = LabelPermission + extra = 0 + fields = [ + "grantee_type", + "grantee_id", + "permission_level", + "granted_by", + "expires_at", + "is_active", + ] + readonly_fields = ["granted_by", "granted_at"] + + def get_queryset(self, request): + """只显示激活的权限""" + qs = super().get_queryset(request) + return qs.filter(is_active=True) + + +@admin.register(Label) +class LabelAdmin(admin.ModelAdmin): + """标签管理后台""" + + list_display = [ + "id", + "name", + "name_zh", + "type", + "owner_type", + "owner_display", + "entity_count", + "is_public", + "sync_source", + "created_at", + ] + + list_filter = [ + "type", + "owner_type", + "is_public", + "sync_source", + "created_at", + ] + + search_fields = [ + "name", + "name_zh", + "owner_id", + ] + + readonly_fields = [ + "created_at", + "updated_at", + "last_synced_at", + "sync_source", + "entity_count_detail", + "data_preview", + ] + + fieldsets = ( + ("基本信息", {"fields": ("name", "name_zh", "type")}), + ("所有者信息", {"fields": ("owner_type", "owner_id", "is_public")}), + ( + "标签数据", + { + "fields": ("data", "data_preview", "entity_count_detail"), + "classes": ("collapse",), + }, + ), + ( + "同步信息", + { + "fields": ("sync_source", "last_synced_at"), + "classes": ("collapse",), + }, + ), + ( + "时间戳", + { + "fields": ("created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + inlines = [LabelPermissionInline] + + actions = ["make_public", "make_private", "export_labels"] + + def owner_display(self, obj): + """显示所有者信息""" + if obj.owner_type == "system": + return format_html('系统') + elif obj.owner_type == "user": + from accounts.models import User + + try: + user = User.objects.get(id=obj.owner_id) + return format_html( + '{}', + obj.owner_id, + user.username, + ) + except User.DoesNotExist: + return f"用户 #{obj.owner_id} (已删除)" + elif obj.owner_type == "organization": + # 假设有 Organization 模型 + return f"组织 #{obj.owner_id}" + return "-" + + owner_display.short_description = "所有者" + + def entity_count(self, obj): + """计算标签包含的实体总数""" + count = 0 + for platform in obj.data.get("platforms", []): + count += len(platform.get("orgs", [])) + count += len(platform.get("repos", [])) + count += len(platform.get("developers", [])) + return count + + entity_count.short_description = "实体数量" + + def entity_count_detail(self, obj): + """详细的实体计数""" + details = [] + for platform in obj.data.get("platforms", []): + platform_name = platform.get("name", "Unknown") + org_count = len(platform.get("orgs", [])) + repo_count = len(platform.get("repos", [])) + dev_count = len(platform.get("developers", [])) + details.append( + f"{platform_name}: {org_count} 组织, {repo_count} 仓库, {dev_count} 开发者" + ) + return "\n".join(details) if details else "无数据" + + entity_count_detail.short_description = "实体详情" + + def data_preview(self, obj): + """JSON 数据预览""" + return format_html( + '
{}
', + json.dumps(obj.data, indent=2, ensure_ascii=False), + ) + + data_preview.short_description = "数据预览" + + def make_public(self, request, queryset): + """批量设为公开""" + updated = queryset.update(is_public=True) + self.message_user(request, f"成功将 {updated} 个标签设为公开") + + make_public.short_description = "设为公开" + + def make_private(self, request, queryset): + """批量设为私有""" + updated = queryset.update(is_public=False) + self.message_user(request, f"成功将 {updated} 个标签设为私有") + + make_private.short_description = "设为私有" + + def export_labels(self, request, queryset): + """导出标签为 JSON""" + labels_data = [] + for label in queryset: + labels_data.append( + { + "name": label.name, + "name_zh": label.name_zh, + "type": label.type, + "data": label.data, + } + ) + + response = HttpResponse( + json.dumps(labels_data, indent=2, ensure_ascii=False), + content_type="application/json", + ) + response["Content-Disposition"] = 'attachment; filename="labels_export.json"' + return response + + export_labels.short_description = "导出选中标签" + + +@admin.register(LabelPermission) +class LabelPermissionAdmin(admin.ModelAdmin): + """标签权限管理后台""" + + list_display = [ + "id", + "label_link", + "grantee_display", + "permission_level", + "status_display", + "granted_by_display", + "granted_at", + "expires_at", + ] + + list_filter = [ + "permission_level", + "grantee_type", + "is_active", + "granted_at", + ] + + search_fields = [ + "label__name", + "label__name_zh", + "grantee_id", + "notes", + ] + + readonly_fields = [ + "granted_at", + "granted_by", + ] + + fieldsets = ( + ( + "权限信息", + {"fields": ("label", "grantee_type", "grantee_id", "permission_level")}, + ), + ( + "授权信息", + {"fields": ("granted_by", "granted_at", "expires_at", "is_active")}, + ), + ( + "备注", + {"fields": ("notes",)}, + ), + ) + + actions = ["activate_permissions", "deactivate_permissions"] + + def label_link(self, obj): + """标签链接""" + return format_html( + '{}', + obj.label.id, + obj.label.name, + ) + + label_link.short_description = "标签" + + def grantee_display(self, obj): + """被授权对象显示""" + if obj.grantee_type == "user": + from accounts.models import User + + try: + user = User.objects.get(id=obj.grantee_id) + return format_html( + '用户: {}', + obj.grantee_id, + user.username, + ) + except User.DoesNotExist: + return f"用户 #{obj.grantee_id} (已删除)" + elif obj.grantee_type == "organization": + return f"组织 #{obj.grantee_id}" + return "-" + + grantee_display.short_description = "被授权对象" + + def granted_by_display(self, obj): + """授权者显示""" + if not obj.granted_by: + return "-" + return format_html( + '{}', + obj.granted_by.id, + obj.granted_by.username, + ) + + granted_by_display.short_description = "授权者" + + def status_display(self, obj): + """权限状态显示""" + if not obj.is_active: + return format_html('已撤销') + if obj.is_expired(): + return format_html('已过期') + return format_html('激活') + + status_display.short_description = "状态" + + def activate_permissions(self, request, queryset): + """激活权限""" + updated = queryset.update(is_active=True) + self.message_user(request, f"成功激活 {updated} 个权限") + + activate_permissions.short_description = "激活选中权限" + + def deactivate_permissions(self, request, queryset): + """停用权限""" + updated = queryset.update(is_active=False) + self.message_user(request, f"成功停用 {updated} 个权限") + + deactivate_permissions.short_description = "停用选中权限" + + +@admin.register(LabelPermissionLog) +class LabelPermissionLogAdmin(admin.ModelAdmin): + """标签权限日志管理后台""" + + list_display = [ + "id", + "permission_link", + "action", + "actor_display", + "timestamp", + ] + + list_filter = [ + "action", + "timestamp", + ] + + search_fields = [ + "permission__label__name", + "actor__username", + ] + + readonly_fields = [ + "permission", + "action", + "actor", + "timestamp", + "details_display", + ] + + fieldsets = ( + ("日志信息", {"fields": ("permission", "action", "actor", "timestamp")}), + ( + "变更详情", + {"fields": ("details_display",)}, + ), + ) + + def has_add_permission(self, request): + """禁止添加日志(只能由系统创建)""" + return False + + def has_delete_permission(self, request, obj=None): + """禁止删除日志(审计需要)""" + return False + + def permission_link(self, obj): + """权限链接""" + return format_html( + '权限 #{}', + obj.permission.id, + obj.permission.id, + ) + + permission_link.short_description = "权限" + + def actor_display(self, obj): + """操作者显示""" + if not obj.actor: + return "系统" + return format_html( + '{}', + obj.actor.id, + obj.actor.username, + ) + + actor_display.short_description = "操作者" + + def details_display(self, obj): + """详情显示""" + return format_html( + "
{}
", + json.dumps(obj.details, indent=2, ensure_ascii=False), + ) + + details_display.short_description = "变更详情" diff --git a/labels/apps.py b/labels/apps.py new file mode 100644 index 0000000..38cd9f8 --- /dev/null +++ b/labels/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class LabelsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "labels" + verbose_name = "标签管理" diff --git a/labels/migrations/0001_initial.py b/labels/migrations/0001_initial.py new file mode 100644 index 0000000..0884d55 --- /dev/null +++ b/labels/migrations/0001_initial.py @@ -0,0 +1,94 @@ +# Generated by Django 5.2.7 on 2025-12-25 09:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Label', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, max_length=200, verbose_name='英文名称')), + ('name_zh', models.CharField(max_length=200, verbose_name='中文名称')), + ('type', models.CharField(choices=[('project', '项目'), ('enterprise', '企业'), ('foundation', '基金会'), ('technology', '技术领域'), ('community', '社区')], db_index=True, max_length=20, verbose_name='标签类型')), + ('owner_type', models.CharField(choices=[('system', '系统'), ('user', '个人'), ('organization', '组织')], db_index=True, default='user', max_length=20, verbose_name='所有者类型')), + ('owner_id', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='所有者ID')), + ('data', models.JSONField(default=dict, verbose_name='标签数据')), + ('is_public', models.BooleanField(default=False, verbose_name='是否公开')), + ('sync_source', models.CharField(blank=True, max_length=50, null=True, verbose_name='同步来源')), + ('last_synced_at', models.DateTimeField(blank=True, null=True, verbose_name='最后同步时间')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '标签', + 'verbose_name_plural': '标签', + 'db_table': 'labels', + 'indexes': [models.Index(fields=['owner_type', 'owner_id'], name='labels_owner_t_09f378_idx'), models.Index(fields=['type', 'is_public'], name='labels_type_c20be6_idx')], + 'constraints': [models.UniqueConstraint(fields=('name', 'owner_type', 'owner_id'), name='unique_label_per_owner')], + }, + ), + migrations.CreateModel( + name='LabelPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('grantee_type', models.CharField(choices=[('user', '用户'), ('organization', '组织')], max_length=20, verbose_name='被授权对象类型')), + ('grantee_id', models.IntegerField(verbose_name='被授权对象ID')), + ('permission_level', models.CharField(choices=[('view', '查看'), ('use', '使用'), ('edit', '编辑'), ('manage', '管理')], max_length=20, verbose_name='权限级别')), + ('granted_at', models.DateTimeField(auto_now_add=True, verbose_name='授权时间')), + ('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='过期时间')), + ('is_active', models.BooleanField(default=True, verbose_name='是否激活')), + ('notes', models.TextField(blank=True, verbose_name='备注')), + ('granted_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='granted_permissions', to=settings.AUTH_USER_MODEL, verbose_name='授权者')), + ('label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permissions', to='labels.label', verbose_name='标签')), + ], + options={ + 'verbose_name': '标签权限', + 'verbose_name_plural': '标签权限', + 'db_table': 'label_permissions', + }, + ), + migrations.CreateModel( + name='LabelPermissionLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('granted', '授予'), ('updated', '更新'), ('revoked', '撤销'), ('expired', '过期')], max_length=20, verbose_name='操作')), + ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='时间戳')), + ('details', models.JSONField(default=dict, verbose_name='变更详情')), + ('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='操作者')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelpermission', verbose_name='权限')), + ], + options={ + 'verbose_name': '标签权限日志', + 'verbose_name_plural': '标签权限日志', + 'db_table': 'label_permission_logs', + 'ordering': ['-timestamp'], + }, + ), + migrations.AddIndex( + model_name='labelpermission', + index=models.Index(fields=['label', 'grantee_type', 'grantee_id'], name='label_permi_label_i_450297_idx'), + ), + migrations.AddIndex( + model_name='labelpermission', + index=models.Index(fields=['grantee_type', 'grantee_id', 'is_active'], name='label_permi_grantee_dc3eb4_idx'), + ), + migrations.AddIndex( + model_name='labelpermission', + index=models.Index(fields=['expires_at'], name='label_permi_expires_686383_idx'), + ), + migrations.AddConstraint( + model_name='labelpermission', + constraint=models.UniqueConstraint(condition=models.Q(('is_active', True)), fields=('label', 'grantee_type', 'grantee_id'), name='unique_active_permission'), + ), + ] diff --git a/labels/migrations/__init__.py b/labels/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/labels/models.py b/labels/models.py new file mode 100644 index 0000000..2cbb0ed --- /dev/null +++ b/labels/models.py @@ -0,0 +1,250 @@ +from django.db import models +from django.utils import timezone + + +class LabelType(models.TextChoices): + """标签类型枚举""" + + PROJECT = "project", "项目" + ENTERPRISE = "enterprise", "企业" + FOUNDATION = "foundation", "基金会" + TECHNOLOGY = "technology", "技术领域" + COMMUNITY = "community", "社区" + + +class OwnerType(models.TextChoices): + """所有者类型枚举""" + + SYSTEM = "system", "系统" # OpenDigger 同步的标签 + USER = "user", "个人" # 用户创建的标签 + ORGANIZATION = "organization", "组织" # 组织创建的标签 + + +class PermissionLevel(models.TextChoices): + """权限级别枚举""" + + VIEW = "view", "查看" # 仅可查看标签及其数据 + USE = "use", "使用" # 可查看和使用标签(积分分发、筛选) + EDIT = "edit", "编辑" # 可查看、使用和编辑标签数据 + MANAGE = "manage", "管理" # 完全控制(包括删除、授权) + + +class GranteeType(models.TextChoices): + """被授权对象类型枚举""" + + USER = "user", "用户" # 授权给个人用户 + ORGANIZATION = "organization", "组织" # 授权给整个组织 + + +class Label(models.Model): + """ + 标签模型,用于分类项目、组织和开发者 + 与 OpenDigger 标签系统集成 + """ + + name = models.CharField(max_length=200, db_index=True, verbose_name="英文名称") + name_zh = models.CharField(max_length=200, verbose_name="中文名称") + type = models.CharField( + max_length=20, + choices=LabelType.choices, + db_index=True, + verbose_name="标签类型", + ) + owner_type = models.CharField( + max_length=20, + choices=OwnerType.choices, + default=OwnerType.USER, + db_index=True, + verbose_name="所有者类型", + ) + owner_id = models.IntegerField( + null=True, blank=True, db_index=True, verbose_name="所有者ID" + ) + data = models.JSONField(default=dict, verbose_name="标签数据") + is_public = models.BooleanField(default=False, verbose_name="是否公开") + sync_source = models.CharField( + max_length=50, null=True, blank=True, verbose_name="同步来源" + ) + last_synced_at = models.DateTimeField( + null=True, blank=True, verbose_name="最后同步时间" + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + db_table = "labels" + verbose_name = "标签" + verbose_name_plural = "标签" + indexes = [ + models.Index(fields=["owner_type", "owner_id"]), + models.Index(fields=["type", "is_public"]), + ] + constraints = [ + models.UniqueConstraint( + fields=["name", "owner_type", "owner_id"], + name="unique_label_per_owner", + ) + ] + + def __str__(self): + return f"{self.name} ({self.get_type_display()})" + + def get_platform_entities(self, platform_name): + """提取特定平台的组织/仓库/开发者""" + for platform in self.data.get("platforms", []): + if platform["name"] == platform_name: + return platform + return None + + def has_access(self, user, required_level="view"): + """ + 检查用户是否可以访问此标签 + 支持通过 LabelPermission 的授权访问 + """ + # 系统标签对所有人可见 + if self.owner_type == OwnerType.SYSTEM: + return required_level == "view" + + # 所有者拥有完全权限 + if self.owner_type == OwnerType.USER and self.owner_id == user.id: + return True + if self.owner_type == OwnerType.ORGANIZATION: + # 检查用户是否是组织管理员 + if hasattr(user, "is_org_admin"): + return user.is_org_admin(self.owner_id) + + # 检查直接授予用户的权限 + user_permission = self.permissions.filter( + grantee_type=GranteeType.USER, grantee_id=user.id, is_active=True + ).first() + if user_permission and user_permission.check_permission(required_level): + return True + + # 检查通过组织授予的权限 + if hasattr(user, "get_organization_ids"): + user_org_ids = user.get_organization_ids() + org_permissions = self.permissions.filter( + grantee_type=GranteeType.ORGANIZATION, + grantee_id__in=user_org_ids, + is_active=True, + ) + for perm in org_permissions: + if perm.check_permission(required_level): + return True + + return False + + def can_edit(self, user): + """检查用户是否可以编辑此标签""" + if self.owner_type == OwnerType.SYSTEM: + return False + return self.has_access(user, required_level="edit") + + def can_manage(self, user): + """检查用户是否可以管理此标签(删除、授权等)""" + if self.owner_type == OwnerType.SYSTEM: + return False + return self.has_access(user, required_level="manage") + + +class LabelPermission(models.Model): + """ + 标签共享权限模型,支持跨用户和跨组织的标签访问控制 + """ + + label = models.ForeignKey( + "Label", + on_delete=models.CASCADE, + related_name="permissions", + verbose_name="标签", + ) + grantee_type = models.CharField( + max_length=20, choices=GranteeType.choices, verbose_name="被授权对象类型" + ) + grantee_id = models.IntegerField(verbose_name="被授权对象ID") + permission_level = models.CharField( + max_length=20, choices=PermissionLevel.choices, verbose_name="权限级别" + ) + granted_by = models.ForeignKey( + "accounts.User", + on_delete=models.SET_NULL, + null=True, + related_name="granted_permissions", + verbose_name="授权者", + ) + granted_at = models.DateTimeField(auto_now_add=True, verbose_name="授权时间") + expires_at = models.DateTimeField(null=True, blank=True, verbose_name="过期时间") + is_active = models.BooleanField(default=True, verbose_name="是否激活") + notes = models.TextField(blank=True, verbose_name="备注") + + class Meta: + db_table = "label_permissions" + verbose_name = "标签权限" + verbose_name_plural = "标签权限" + indexes = [ + models.Index(fields=["label", "grantee_type", "grantee_id"]), + models.Index(fields=["grantee_type", "grantee_id", "is_active"]), + models.Index(fields=["expires_at"]), + ] + constraints = [ + models.UniqueConstraint( + fields=["label", "grantee_type", "grantee_id"], + condition=models.Q(is_active=True), + name="unique_active_permission", + ) + ] + + def __str__(self): + return f"{self.label.name} - {self.get_permission_level_display()} to {self.grantee_type}:{self.grantee_id}" + + def is_expired(self): + """检查权限是否已过期""" + if not self.expires_at: + return False + return timezone.now() > self.expires_at + + def check_permission(self, required_level): + """检查是否具有所需权限级别""" + if not self.is_active or self.is_expired(): + return False + + # 权限级别层级: VIEW < USE < EDIT < MANAGE + levels = ["view", "use", "edit", "manage"] + current_index = levels.index(self.permission_level) + required_index = levels.index(required_level) + return current_index >= required_index + + +class LabelPermissionLog(models.Model): + """标签权限变更审计日志""" + + ACTION_CHOICES = [ + ("granted", "授予"), + ("updated", "更新"), + ("revoked", "撤销"), + ("expired", "过期"), + ] + + permission = models.ForeignKey( + LabelPermission, on_delete=models.CASCADE, verbose_name="权限" + ) + action = models.CharField( + max_length=20, choices=ACTION_CHOICES, verbose_name="操作" + ) + actor = models.ForeignKey( + "accounts.User", + on_delete=models.SET_NULL, + null=True, + verbose_name="操作者", + ) + timestamp = models.DateTimeField(auto_now_add=True, verbose_name="时间戳") + details = models.JSONField(default=dict, verbose_name="变更详情") + + class Meta: + db_table = "label_permission_logs" + verbose_name = "标签权限日志" + verbose_name_plural = "标签权限日志" + ordering = ["-timestamp"] + + def __str__(self): + return f"{self.get_action_display()} - {self.permission} at {self.timestamp}" From f88bde5ff0ed1fc73dff6637cf6e1508ea70e416 Mon Sep 17 00:00:00 2001 From: bestony Date: Thu, 25 Dec 2025 17:44:48 +0800 Subject: [PATCH 02/10] feat: add create label feature --- accounts/urls.py | 27 ++ accounts/views.py | 332 ++++++++++++++++++ templates/accounts/label_form.html | 77 ++++ templates/accounts/label_list.html | 150 ++++++++ .../accounts/label_permission_grant.html | 84 +++++ templates/accounts/label_permissions.html | 154 ++++++++ templates/partials/_sidebar.html | 6 + templates/profile.html | 8 + 8 files changed, 838 insertions(+) create mode 100644 templates/accounts/label_form.html create mode 100644 templates/accounts/label_list.html create mode 100644 templates/accounts/label_permission_grant.html create mode 100644 templates/accounts/label_permissions.html diff --git a/accounts/urls.py b/accounts/urls.py index c602fcb..b601ab8 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -7,6 +7,13 @@ change_email_view, change_password_view, disconnect_social_account, + label_create_view, + label_delete_view, + label_edit_view, + label_list_view, + label_permission_grant_view, + label_permission_revoke_view, + label_permissions_view, logout_view, merge_agree_view, merge_reject_view, @@ -145,4 +152,24 @@ organization_member_remove, name="organization_member_remove", ), + # Label management URLs + path("labels/", label_list_view, name="label_list"), + path("labels/create/", label_create_view, name="label_create"), + path("labels//edit/", label_edit_view, name="label_edit"), + path("labels//delete/", label_delete_view, name="label_delete"), + path( + "labels//permissions/", + label_permissions_view, + name="label_permissions", + ), + path( + "labels//permissions/grant/", + label_permission_grant_view, + name="label_permission_grant", + ), + path( + "permissions//revoke/", + label_permission_revoke_view, + name="label_permission_revoke", + ), ] diff --git a/accounts/views.py b/accounts/views.py index 9651db5..817333c 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1594,3 +1594,335 @@ def organization_delete(request, slug): transaction.on_commit(lambda f=avatar_file: f.delete(save=False)) messages.success(request, f"组织 {org_name} 已删除。") return redirect("accounts:organization_list") + + +# Label management views + + +@login_required +def label_list_view(request): + """Display user's labels.""" + from labels.models import Label, OwnerType + + # Get user's labels + user_labels = Label.objects.filter( + owner_type=OwnerType.USER, owner_id=request.user.id + ).order_by("-created_at") + + # Get labels shared with user + shared_labels = [] + from labels.models import GranteeType, LabelPermission + + label_permissions = LabelPermission.objects.filter( + grantee_type=GranteeType.USER, grantee_id=request.user.id, is_active=True + ).select_related("label") + + for perm in label_permissions: + if not perm.is_expired(): + label = perm.label + label.permission_level = perm.permission_level + shared_labels.append(label) + + return render( + request, + "accounts/label_list.html", + {"user_labels": user_labels, "shared_labels": shared_labels}, + ) + + +@login_required +def label_create_view(request): + """Create a new label.""" + from labels.models import Label, LabelType, OwnerType + + if request.method == "POST": + name = request.POST.get("name", "").strip() + name_zh = request.POST.get("name_zh", "").strip() + label_type = request.POST.get("type", "") + is_public = request.POST.get("is_public") == "1" + + errors = {} + if not name: + errors["name"] = "英文名称不能为空" + if not name_zh: + errors["name_zh"] = "中文名称不能为空" + if label_type not in dict(LabelType.choices): + errors["type"] = "请选择有效的标签类型" + + # Check uniqueness + if name and Label.objects.filter( + name=name, owner_type=OwnerType.USER, owner_id=request.user.id + ).exists(): + errors["name"] = "该标签名称已存在" + + if not errors: + label = Label.objects.create( + name=name, + name_zh=name_zh, + type=label_type, + owner_type=OwnerType.USER, + owner_id=request.user.id, + is_public=is_public, + ) + messages.success(request, f"标签 {label.name_zh} 创建成功") + return redirect("accounts:label_list") + + context = { + "errors": errors, + "name": name, + "name_zh": name_zh, + "type": label_type, + "is_public": is_public, + "label_types": LabelType.choices, + } + return render(request, "accounts/label_form.html", context) + + return render( + request, "accounts/label_form.html", {"label_types": LabelType.choices} + ) + + +@login_required +def label_edit_view(request, label_id): + """Edit an existing label.""" + from labels.models import Label, LabelType + + label = get_object_or_404(Label, id=label_id) + + if not label.can_edit(request.user): + messages.error(request, "您没有权限编辑此标签") + return redirect("accounts:label_list") + + if request.method == "POST": + name = request.POST.get("name", "").strip() + name_zh = request.POST.get("name_zh", "").strip() + label_type = request.POST.get("type", "") + is_public = request.POST.get("is_public") == "1" + + errors = {} + if not name: + errors["name"] = "英文名称不能为空" + if not name_zh: + errors["name_zh"] = "中文名称不能为空" + if label_type not in dict(LabelType.choices): + errors["type"] = "请选择有效的标签类型" + + # Check uniqueness (exclude current label) + if name and Label.objects.filter( + name=name, owner_type=label.owner_type, owner_id=label.owner_id + ).exclude(id=label.id).exists(): + errors["name"] = "该标签名称已存在" + + if not errors: + label.name = name + label.name_zh = name_zh + label.type = label_type + label.is_public = is_public + label.save() + messages.success(request, f"标签 {label.name_zh} 更新成功") + return redirect("accounts:label_list") + + context = { + "label": label, + "errors": errors, + "name": name, + "name_zh": name_zh, + "type": label_type, + "is_public": is_public, + "label_types": LabelType.choices, + } + return render(request, "accounts/label_form.html", context) + + return render( + request, + "accounts/label_form.html", + {"label": label, "label_types": LabelType.choices}, + ) + + +@login_required +@require_POST +def label_delete_view(request, label_id): + """Delete a label.""" + from labels.models import Label + + label = get_object_or_404(Label, id=label_id) + + if not label.can_manage(request.user): + messages.error(request, "您没有权限删除此标签") + return redirect("accounts:label_list") + + label_name = label.name_zh + label.delete() + messages.success(request, f"标签 {label_name} 已删除") + return redirect("accounts:label_list") + + +@login_required +def label_permissions_view(request, label_id): + """Manage label permissions.""" + from labels.models import Label, LabelPermission + + label = get_object_or_404(Label, id=label_id) + + if not label.can_manage(request.user): + messages.error(request, "您没有权限管理此标签的权限") + return redirect("accounts:label_list") + + permissions = LabelPermission.objects.filter(label=label, is_active=True).order_by( + "-granted_at" + ) + + return render( + request, + "accounts/label_permissions.html", + {"label": label, "permissions": permissions}, + ) + + +@login_required +def label_permission_grant_view(request, label_id): + """Grant permission to a user.""" + from labels.models import ( + GranteeType, + Label, + LabelPermission, + LabelPermissionLog, + PermissionLevel, + ) + + label = get_object_or_404(Label, id=label_id) + + if not label.can_manage(request.user): + messages.error(request, "您没有权限管理此标签的权限") + return redirect("accounts:label_list") + + if request.method == "POST": + username = request.POST.get("username", "").strip() + permission_level = request.POST.get("permission_level", "") + expires_days = request.POST.get("expires_days", "").strip() + notes = request.POST.get("notes", "").strip() + + errors = {} + if not username: + errors["username"] = "请输入用户名" + if permission_level not in dict(PermissionLevel.choices): + errors["permission_level"] = "请选择有效的权限级别" + + # Find user + User = get_user_model() + try: + target_user = User.objects.get(username=username) + except User.DoesNotExist: + errors["username"] = f"用户 {username} 不存在" + target_user = None + + # Check if user is trying to grant permission to themselves + if target_user and target_user.id == request.user.id: + errors["username"] = "不能授权给自己" + target_user = None + + # Calculate expiration date + expires_at = None + if expires_days: + try: + days = int(expires_days) + if days > 0: + expires_at = timezone.now() + timedelta(days=days) + except ValueError: + errors["expires_days"] = "请输入有效的天数" + + if not errors and target_user: + # Check if permission already exists + existing_perm = LabelPermission.objects.filter( + label=label, + grantee_type=GranteeType.USER, + grantee_id=target_user.id, + is_active=True, + ).first() + + if existing_perm: + # Update existing permission + existing_perm.permission_level = permission_level + existing_perm.expires_at = expires_at + existing_perm.notes = notes + existing_perm.save() + + # Log the update + LabelPermissionLog.objects.create( + permission=existing_perm, + action="updated", + actor=request.user, + details={ + "permission_level": permission_level, + "expires_at": expires_at.isoformat() if expires_at else None, + }, + ) + messages.success(request, f"已更新授予 {username} 的权限") + else: + # Create new permission + perm = LabelPermission.objects.create( + label=label, + grantee_type=GranteeType.USER, + grantee_id=target_user.id, + permission_level=permission_level, + granted_by=request.user, + expires_at=expires_at, + notes=notes, + ) + + # Log the grant + LabelPermissionLog.objects.create( + permission=perm, + action="granted", + actor=request.user, + details={ + "permission_level": permission_level, + "expires_at": expires_at.isoformat() if expires_at else None, + }, + ) + messages.success(request, f"已授予 {username} 权限") + + return redirect("accounts:label_permissions", label_id=label.id) + + context = { + "label": label, + "errors": errors, + "username": username, + "permission_level": permission_level, + "expires_days": expires_days, + "notes": notes, + "permission_levels": PermissionLevel.choices, + } + return render(request, "accounts/label_permission_grant.html", context) + + return render( + request, + "accounts/label_permission_grant.html", + {"label": label, "permission_levels": PermissionLevel.choices}, + ) + + +@login_required +@require_POST +def label_permission_revoke_view(request, permission_id): + """Revoke a permission.""" + from labels.models import LabelPermission, LabelPermissionLog + + permission = get_object_or_404(LabelPermission, id=permission_id) + + if not permission.label.can_manage(request.user): + messages.error(request, "您没有权限撤销此权限") + return redirect("accounts:label_list") + + label_id = permission.label.id + permission.is_active = False + permission.save() + + # Log the revocation + LabelPermissionLog.objects.create( + permission=permission, action="revoked", actor=request.user, details={} + ) + + messages.success(request, "权限已撤销") + return redirect("accounts:label_permissions", label_id=label_id) diff --git a/templates/accounts/label_form.html b/templates/accounts/label_form.html new file mode 100644 index 0000000..c694093 --- /dev/null +++ b/templates/accounts/label_form.html @@ -0,0 +1,77 @@ +{% extends "horizontal_base.html" %} + +{% block title %}{% if label %}编辑标签{% else %}创建标签{% endif %}{% endblock title %} + +{% block content %} +
+
+

{% if label %}编辑标签{% else %}创建标签{% endif %}

+
+ +
+
+
+
+
+
+ 标签信息 +
+
+ {% csrf_token %} +
+ + + {% if errors.name %} +
{{ errors.name }}
+ {% endif %} + 用于系统识别的英文标识 +
+
+ + + {% if errors.name_zh %} +
{{ errors.name_zh }}
+ {% endif %} + 用于显示的中文名称 +
+
+ + + {% if errors.type %} +
{{ errors.type }}
+ {% endif %} +
+
+
+ + +
+ 公开标签可被其他用户查看和使用 +
+
+ 取消 + +
+
+
+
+
+
+{% endblock content %} diff --git a/templates/accounts/label_list.html b/templates/accounts/label_list.html new file mode 100644 index 0000000..06039ab --- /dev/null +++ b/templates/accounts/label_list.html @@ -0,0 +1,150 @@ +{% extends "horizontal_base.html" %} + +{% block title %}标签管理{% endblock title %} + +{% block content %} +
+
+

标签管理

+
+ +
+
+
+ +
+
+
+ 我的标签 +
+ {% if user_labels %} +
+ + + + + + + + + + + + {% for label in user_labels %} + + + + + + + + {% endfor %} + +
名称类型公开创建时间操作
+
+ {{ label.name_zh }} +
+ {{ label.name }} +
+
+ {{ label.get_type_display }} + + {% if label.is_public %} + + 公开 + + {% else %} + + 私有 + + {% endif %} + {{ label.created_at|date:"Y-m-d H:i" }} +
+ + + + + + +
+ {% csrf_token %} + +
+
+
+
+ {% else %} + + {% endif %} +
+
+
+ {% if shared_labels %} +
+ +
+
+
+ 共享给我的标签 +
+
+ + + + + + + + + + + {% for label in shared_labels %} + + + + + + + {% endfor %} + +
名称类型权限级别所有者
+
+ {{ label.name_zh }} +
+ {{ label.name }} +
+
+ {{ label.get_type_display }} + + + {% if label.permission_level == 'view' %} + 查看 + {% elif label.permission_level == 'use' %} + 使用 + {% elif label.permission_level == 'edit' %} + 编辑 + {% elif label.permission_level == 'manage' %} + 管理 + {% endif %} + + + + {{ label.get_owner_type_display }} + +
+
+
+
+
+ {% endif %} +
+{% endblock content %} diff --git a/templates/accounts/label_permission_grant.html b/templates/accounts/label_permission_grant.html new file mode 100644 index 0000000..7688768 --- /dev/null +++ b/templates/accounts/label_permission_grant.html @@ -0,0 +1,84 @@ +{% extends "horizontal_base.html" %} + +{% block title %}授予标签权限{% endblock title %} + +{% block content %} +
+
+

授予标签权限

+

标签: {{ label.name_zh }} ({{ label.name }})

+
+ +
+
+
+
+
+
+ 授权信息 +
+
+ {% csrf_token %} +
+ + + {% if errors.username %} +
{{ errors.username }}
+ {% endif %} + 输入要授予权限的用户的用户名 +
+
+ + + {% if errors.permission_level %} +
{{ errors.permission_level }}
+ {% endif %} +
+
+ + + {% if errors.expires_days %} +
{{ errors.expires_days }}
+ {% endif %} + 留空表示权限永久有效 +
+
+ + + 可选,用于说明授权原因 +
+ +
+ 取消 + +
+
+
+
+
+
+{% endblock content %} diff --git a/templates/accounts/label_permissions.html b/templates/accounts/label_permissions.html new file mode 100644 index 0000000..c005a47 --- /dev/null +++ b/templates/accounts/label_permissions.html @@ -0,0 +1,154 @@ +{% extends "horizontal_base.html" %} + +{% block title %}标签权限管理{% endblock title %} + +{% block content %} +
+
+

标签权限管理

+

标签: {{ label.name_zh }} ({{ label.name }})

+
+ +
+
+
+
+
+
+ 权限列表 +
+ {% if permissions %} +
+ + + + + + + + + + + + + + {% for perm in permissions %} + + + + + + + + + + {% endfor %} + +
用户权限级别授予者授予时间过期时间备注操作
+ {% if perm.grantee_type == 'user' %} + + ID: {{ perm.grantee_id }} + + {% else %} + + 组织 ID: {{ perm.grantee_id }} + + {% endif %} + + {% if perm.permission_level == 'view' %} + + 查看 + + {% elif perm.permission_level == 'use' %} + + 使用 + + {% elif perm.permission_level == 'edit' %} + + 编辑 + + {% elif perm.permission_level == 'manage' %} + + 管理 + + {% endif %} + {{ perm.granted_by.username|default:"系统" }}{{ perm.granted_at|date:"Y-m-d H:i" }} + {% if perm.expires_at %} + {{ perm.expires_at|date:"Y-m-d H:i" }} + {% if perm.is_expired %} + 已过期 + {% endif %} + {% else %} + 永久 + {% endif %} + + {% if perm.notes %} + {{ perm.notes|truncatewords:10 }} + {% else %} + - + {% endif %} + +
+ {% csrf_token %} + +
+
+
+ {% else %} + + {% endif %} +
+
+
+
+ +
+
+
+
+
+ 权限级别说明 +
+
    +
  • + + 查看 + + - 仅可查看标签及其数据 +
  • +
  • + + 使用 + + - 可查看和使用标签(积分分发、筛选) +
  • +
  • + + 编辑 + + - 可查看、使用和编辑标签数据 +
  • +
  • + + 管理 + + - 完全控制(包括删除、授权) +
  • +
+
+
+
+
+{% endblock content %} diff --git a/templates/partials/_sidebar.html b/templates/partials/_sidebar.html index 17c1d20..72033d5 100644 --- a/templates/partials/_sidebar.html +++ b/templates/partials/_sidebar.html @@ -34,6 +34,12 @@ 我的积分 +