FossilRepo
Add project groups and org-level roles with web UI Project Groups: lightweight grouping of related projects (e.g., Fossil SCM source + forum + docs). ProjectGroup model with FK on Project, sidebar shows groups as headers with nested project trees, CRUD views for group management. Extracted _sidebar_project.html partial to avoid duplication. 47 tests. Org Roles: predefined roles (Admin/Manager/Developer/Viewer) with permission bundles, replacing Django admin for permission management. OrgRole model with M2M to auth.Permission, role FK on OrganizationMember. Roles sync to Django Groups via apply_to_user(). seed_roles management command for initialization. Role list/detail views, role assignment on user create/edit, role column in member list. 39 tests (69 total with seed/apply mechanics).
f13cb5fe74eb970b153e0716268c968d2eea8b9373a5129a99921d17b3bcb264
| --- core/context_processors.py | ||
| +++ core/context_processors.py | ||
| @@ -1,17 +1,32 @@ | ||
| 1 | 1 | from pages.models import Page |
| 2 | -from projects.models import Project | |
| 2 | +from projects.models import Project, ProjectGroup | |
| 3 | 3 | |
| 4 | 4 | |
| 5 | 5 | def sidebar(request): |
| 6 | 6 | if not request.user.is_authenticated: |
| 7 | 7 | return {} |
| 8 | 8 | |
| 9 | - projects = Project.objects.all() | |
| 9 | + projects = Project.objects.all().select_related("group") | |
| 10 | 10 | pages = Page.objects.filter(is_published=True) |
| 11 | 11 | if request.user.has_perm("pages.change_page") or request.user.is_superuser: |
| 12 | 12 | pages = Page.objects.all() |
| 13 | 13 | |
| 14 | + # Build grouped structure for sidebar | |
| 15 | + groups = ProjectGroup.objects.filter(deleted_at__isnull=True) | |
| 16 | + | |
| 17 | + grouped_projects = [] | |
| 18 | + grouped_ids = set() | |
| 19 | + for group in groups: | |
| 20 | + group_projects = [p for p in projects if p.group_id == group.id] | |
| 21 | + if group_projects: | |
| 22 | + grouped_projects.append({"group": group, "projects": group_projects}) | |
| 23 | + grouped_ids.update(p.id for p in group_projects) | |
| 24 | + | |
| 25 | + ungrouped_projects = [p for p in projects if p.id not in grouped_ids] | |
| 26 | + | |
| 14 | 27 | return { |
| 15 | 28 | "sidebar_projects": projects, |
| 29 | + "sidebar_grouped": grouped_projects, | |
| 30 | + "sidebar_ungrouped": ungrouped_projects, | |
| 16 | 31 | "sidebar_pages": pages, |
| 17 | 32 | } |
| 18 | 33 |
| --- core/context_processors.py | |
| +++ core/context_processors.py | |
| @@ -1,17 +1,32 @@ | |
| 1 | from pages.models import Page |
| 2 | from projects.models import Project |
| 3 | |
| 4 | |
| 5 | def sidebar(request): |
| 6 | if not request.user.is_authenticated: |
| 7 | return {} |
| 8 | |
| 9 | projects = Project.objects.all() |
| 10 | pages = Page.objects.filter(is_published=True) |
| 11 | if request.user.has_perm("pages.change_page") or request.user.is_superuser: |
| 12 | pages = Page.objects.all() |
| 13 | |
| 14 | return { |
| 15 | "sidebar_projects": projects, |
| 16 | "sidebar_pages": pages, |
| 17 | } |
| 18 |
| --- core/context_processors.py | |
| +++ core/context_processors.py | |
| @@ -1,17 +1,32 @@ | |
| 1 | from pages.models import Page |
| 2 | from projects.models import Project, ProjectGroup |
| 3 | |
| 4 | |
| 5 | def sidebar(request): |
| 6 | if not request.user.is_authenticated: |
| 7 | return {} |
| 8 | |
| 9 | projects = Project.objects.all().select_related("group") |
| 10 | pages = Page.objects.filter(is_published=True) |
| 11 | if request.user.has_perm("pages.change_page") or request.user.is_superuser: |
| 12 | pages = Page.objects.all() |
| 13 | |
| 14 | # Build grouped structure for sidebar |
| 15 | groups = ProjectGroup.objects.filter(deleted_at__isnull=True) |
| 16 | |
| 17 | grouped_projects = [] |
| 18 | grouped_ids = set() |
| 19 | for group in groups: |
| 20 | group_projects = [p for p in projects if p.group_id == group.id] |
| 21 | if group_projects: |
| 22 | grouped_projects.append({"group": group, "projects": group_projects}) |
| 23 | grouped_ids.update(p.id for p in group_projects) |
| 24 | |
| 25 | ungrouped_projects = [p for p in projects if p.id not in grouped_ids] |
| 26 | |
| 27 | return { |
| 28 | "sidebar_projects": projects, |
| 29 | "sidebar_grouped": grouped_projects, |
| 30 | "sidebar_ungrouped": ungrouped_projects, |
| 31 | "sidebar_pages": pages, |
| 32 | } |
| 33 |
| --- core/permissions.py | ||
| +++ core/permissions.py | ||
| @@ -25,10 +25,16 @@ | ||
| 25 | 25 | TEAM_VIEW = "organization.view_team" |
| 26 | 26 | TEAM_ADD = "organization.add_team" |
| 27 | 27 | TEAM_CHANGE = "organization.change_team" |
| 28 | 28 | TEAM_DELETE = "organization.delete_team" |
| 29 | 29 | |
| 30 | + # Project Groups | |
| 31 | + PROJECT_GROUP_VIEW = "projects.view_projectgroup" | |
| 32 | + PROJECT_GROUP_ADD = "projects.add_projectgroup" | |
| 33 | + PROJECT_GROUP_CHANGE = "projects.change_projectgroup" | |
| 34 | + PROJECT_GROUP_DELETE = "projects.delete_projectgroup" | |
| 35 | + | |
| 30 | 36 | # Projects |
| 31 | 37 | PROJECT_VIEW = "projects.view_project" |
| 32 | 38 | PROJECT_ADD = "projects.add_project" |
| 33 | 39 | PROJECT_CHANGE = "projects.change_project" |
| 34 | 40 | PROJECT_DELETE = "projects.delete_project" |
| 35 | 41 |
| --- core/permissions.py | |
| +++ core/permissions.py | |
| @@ -25,10 +25,16 @@ | |
| 25 | TEAM_VIEW = "organization.view_team" |
| 26 | TEAM_ADD = "organization.add_team" |
| 27 | TEAM_CHANGE = "organization.change_team" |
| 28 | TEAM_DELETE = "organization.delete_team" |
| 29 | |
| 30 | # Projects |
| 31 | PROJECT_VIEW = "projects.view_project" |
| 32 | PROJECT_ADD = "projects.add_project" |
| 33 | PROJECT_CHANGE = "projects.change_project" |
| 34 | PROJECT_DELETE = "projects.delete_project" |
| 35 |
| --- core/permissions.py | |
| +++ core/permissions.py | |
| @@ -25,10 +25,16 @@ | |
| 25 | TEAM_VIEW = "organization.view_team" |
| 26 | TEAM_ADD = "organization.add_team" |
| 27 | TEAM_CHANGE = "organization.change_team" |
| 28 | TEAM_DELETE = "organization.delete_team" |
| 29 | |
| 30 | # Project Groups |
| 31 | PROJECT_GROUP_VIEW = "projects.view_projectgroup" |
| 32 | PROJECT_GROUP_ADD = "projects.add_projectgroup" |
| 33 | PROJECT_GROUP_CHANGE = "projects.change_projectgroup" |
| 34 | PROJECT_GROUP_DELETE = "projects.delete_projectgroup" |
| 35 | |
| 36 | # Projects |
| 37 | PROJECT_VIEW = "projects.view_project" |
| 38 | PROJECT_ADD = "projects.add_project" |
| 39 | PROJECT_CHANGE = "projects.change_project" |
| 40 | PROJECT_DELETE = "projects.delete_project" |
| 41 |
| --- organization/admin.py | ||
| +++ organization/admin.py | ||
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | from django.contrib import admin |
| 2 | 2 | |
| 3 | 3 | from core.admin import BaseCoreAdmin |
| 4 | 4 | |
| 5 | -from .models import Organization, OrganizationMember, Team | |
| 5 | +from .models import Organization, OrganizationMember, OrgRole, Team | |
| 6 | 6 | |
| 7 | 7 | |
| 8 | 8 | class OrganizationMemberInline(admin.TabularInline): |
| 9 | 9 | model = OrganizationMember |
| 10 | 10 | extra = 0 |
| @@ -15,10 +15,16 @@ | ||
| 15 | 15 | class OrganizationAdmin(BaseCoreAdmin): |
| 16 | 16 | list_display = ("name", "slug", "website", "created_at") |
| 17 | 17 | search_fields = ("name", "slug") |
| 18 | 18 | inlines = [OrganizationMemberInline] |
| 19 | 19 | |
| 20 | + | |
| 21 | +@admin.register(OrgRole) | |
| 22 | +class OrgRoleAdmin(BaseCoreAdmin): | |
| 23 | + list_display = ("name", "slug", "is_default", "created_at") | |
| 24 | + filter_horizontal = ("permissions",) | |
| 25 | + | |
| 20 | 26 | |
| 21 | 27 | @admin.register(Team) |
| 22 | 28 | class TeamAdmin(BaseCoreAdmin): |
| 23 | 29 | list_display = ("name", "slug", "organization", "created_at") |
| 24 | 30 | search_fields = ("name", "slug") |
| @@ -26,8 +32,8 @@ | ||
| 26 | 32 | filter_horizontal = ("members",) |
| 27 | 33 | |
| 28 | 34 | |
| 29 | 35 | @admin.register(OrganizationMember) |
| 30 | 36 | class OrganizationMemberAdmin(BaseCoreAdmin): |
| 31 | - list_display = ("member", "organization", "is_active", "created_at") | |
| 32 | - list_filter = ("is_active",) | |
| 37 | + list_display = ("member", "organization", "role", "is_active", "created_at") | |
| 38 | + list_filter = ("is_active", "role") | |
| 33 | 39 | raw_id_fields = ("member", "organization") |
| 34 | 40 |
| --- organization/admin.py | |
| +++ organization/admin.py | |
| @@ -1,10 +1,10 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import Organization, OrganizationMember, Team |
| 6 | |
| 7 | |
| 8 | class OrganizationMemberInline(admin.TabularInline): |
| 9 | model = OrganizationMember |
| 10 | extra = 0 |
| @@ -15,10 +15,16 @@ | |
| 15 | class OrganizationAdmin(BaseCoreAdmin): |
| 16 | list_display = ("name", "slug", "website", "created_at") |
| 17 | search_fields = ("name", "slug") |
| 18 | inlines = [OrganizationMemberInline] |
| 19 | |
| 20 | |
| 21 | @admin.register(Team) |
| 22 | class TeamAdmin(BaseCoreAdmin): |
| 23 | list_display = ("name", "slug", "organization", "created_at") |
| 24 | search_fields = ("name", "slug") |
| @@ -26,8 +32,8 @@ | |
| 26 | filter_horizontal = ("members",) |
| 27 | |
| 28 | |
| 29 | @admin.register(OrganizationMember) |
| 30 | class OrganizationMemberAdmin(BaseCoreAdmin): |
| 31 | list_display = ("member", "organization", "is_active", "created_at") |
| 32 | list_filter = ("is_active",) |
| 33 | raw_id_fields = ("member", "organization") |
| 34 |
| --- organization/admin.py | |
| +++ organization/admin.py | |
| @@ -1,10 +1,10 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import Organization, OrganizationMember, OrgRole, Team |
| 6 | |
| 7 | |
| 8 | class OrganizationMemberInline(admin.TabularInline): |
| 9 | model = OrganizationMember |
| 10 | extra = 0 |
| @@ -15,10 +15,16 @@ | |
| 15 | class OrganizationAdmin(BaseCoreAdmin): |
| 16 | list_display = ("name", "slug", "website", "created_at") |
| 17 | search_fields = ("name", "slug") |
| 18 | inlines = [OrganizationMemberInline] |
| 19 | |
| 20 | |
| 21 | @admin.register(OrgRole) |
| 22 | class OrgRoleAdmin(BaseCoreAdmin): |
| 23 | list_display = ("name", "slug", "is_default", "created_at") |
| 24 | filter_horizontal = ("permissions",) |
| 25 | |
| 26 | |
| 27 | @admin.register(Team) |
| 28 | class TeamAdmin(BaseCoreAdmin): |
| 29 | list_display = ("name", "slug", "organization", "created_at") |
| 30 | search_fields = ("name", "slug") |
| @@ -26,8 +32,8 @@ | |
| 32 | filter_horizontal = ("members",) |
| 33 | |
| 34 | |
| 35 | @admin.register(OrganizationMember) |
| 36 | class OrganizationMemberAdmin(BaseCoreAdmin): |
| 37 | list_display = ("member", "organization", "role", "is_active", "created_at") |
| 38 | list_filter = ("is_active", "role") |
| 39 | raw_id_fields = ("member", "organization") |
| 40 |
| --- organization/forms.py | ||
| +++ organization/forms.py | ||
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | from django import forms |
| 2 | 2 | from django.contrib.auth.models import User |
| 3 | 3 | from django.contrib.auth.password_validation import validate_password |
| 4 | 4 | from django.core.exceptions import ValidationError |
| 5 | 5 | |
| 6 | -from .models import Organization, Team | |
| 6 | +from .models import Organization, OrgRole, Team | |
| 7 | 7 | |
| 8 | 8 | tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 9 | 9 | |
| 10 | 10 | |
| 11 | 11 | class OrganizationSettingsForm(forms.ModelForm): |
| @@ -66,10 +66,16 @@ | ||
| 66 | 66 | password2 = forms.CharField( |
| 67 | 67 | label="Confirm Password", |
| 68 | 68 | widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Confirm password"}), |
| 69 | 69 | strip=False, |
| 70 | 70 | ) |
| 71 | + role = forms.ModelChoiceField( | |
| 72 | + queryset=OrgRole.objects.filter(deleted_at__isnull=True), | |
| 73 | + required=False, | |
| 74 | + empty_label="No role", | |
| 75 | + widget=forms.Select(attrs={"class": tw}), | |
| 76 | + ) | |
| 71 | 77 | |
| 72 | 78 | class Meta: |
| 73 | 79 | model = User |
| 74 | 80 | fields = ["username", "email", "first_name", "last_name"] |
| 75 | 81 | widgets = { |
| @@ -102,10 +108,17 @@ | ||
| 102 | 108 | user.save() |
| 103 | 109 | return user |
| 104 | 110 | |
| 105 | 111 | |
| 106 | 112 | class UserEditForm(forms.ModelForm): |
| 113 | + role = forms.ModelChoiceField( | |
| 114 | + queryset=OrgRole.objects.filter(deleted_at__isnull=True), | |
| 115 | + required=False, | |
| 116 | + empty_label="No role", | |
| 117 | + widget=forms.Select(attrs={"class": tw}), | |
| 118 | + ) | |
| 119 | + | |
| 107 | 120 | class Meta: |
| 108 | 121 | model = User |
| 109 | 122 | fields = ["email", "first_name", "last_name", "is_active", "is_staff"] |
| 110 | 123 | widgets = { |
| 111 | 124 | "email": forms.EmailInput(attrs={"class": tw, "placeholder": "[email protected]"}), |
| 112 | 125 | |
| 113 | 126 | ADDED organization/management/__init__.py |
| 114 | 127 | ADDED organization/management/commands/__init__.py |
| 115 | 128 | ADDED organization/management/commands/seed_roles.py |
| 116 | 129 | ADDED organization/migrations/0003_historicalorgrole_orgrole_and_more.py |
| --- organization/forms.py | |
| +++ organization/forms.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | from django import forms |
| 2 | from django.contrib.auth.models import User |
| 3 | from django.contrib.auth.password_validation import validate_password |
| 4 | from django.core.exceptions import ValidationError |
| 5 | |
| 6 | from .models import Organization, Team |
| 7 | |
| 8 | tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 9 | |
| 10 | |
| 11 | class OrganizationSettingsForm(forms.ModelForm): |
| @@ -66,10 +66,16 @@ | |
| 66 | password2 = forms.CharField( |
| 67 | label="Confirm Password", |
| 68 | widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Confirm password"}), |
| 69 | strip=False, |
| 70 | ) |
| 71 | |
| 72 | class Meta: |
| 73 | model = User |
| 74 | fields = ["username", "email", "first_name", "last_name"] |
| 75 | widgets = { |
| @@ -102,10 +108,17 @@ | |
| 102 | user.save() |
| 103 | return user |
| 104 | |
| 105 | |
| 106 | class UserEditForm(forms.ModelForm): |
| 107 | class Meta: |
| 108 | model = User |
| 109 | fields = ["email", "first_name", "last_name", "is_active", "is_staff"] |
| 110 | widgets = { |
| 111 | "email": forms.EmailInput(attrs={"class": tw, "placeholder": "[email protected]"}), |
| 112 | |
| 113 | DDED organization/management/__init__.py |
| 114 | DDED organization/management/commands/__init__.py |
| 115 | DDED organization/management/commands/seed_roles.py |
| 116 | DDED organization/migrations/0003_historicalorgrole_orgrole_and_more.py |
| --- organization/forms.py | |
| +++ organization/forms.py | |
| @@ -1,11 +1,11 @@ | |
| 1 | from django import forms |
| 2 | from django.contrib.auth.models import User |
| 3 | from django.contrib.auth.password_validation import validate_password |
| 4 | from django.core.exceptions import ValidationError |
| 5 | |
| 6 | from .models import Organization, OrgRole, Team |
| 7 | |
| 8 | tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 9 | |
| 10 | |
| 11 | class OrganizationSettingsForm(forms.ModelForm): |
| @@ -66,10 +66,16 @@ | |
| 66 | password2 = forms.CharField( |
| 67 | label="Confirm Password", |
| 68 | widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Confirm password"}), |
| 69 | strip=False, |
| 70 | ) |
| 71 | role = forms.ModelChoiceField( |
| 72 | queryset=OrgRole.objects.filter(deleted_at__isnull=True), |
| 73 | required=False, |
| 74 | empty_label="No role", |
| 75 | widget=forms.Select(attrs={"class": tw}), |
| 76 | ) |
| 77 | |
| 78 | class Meta: |
| 79 | model = User |
| 80 | fields = ["username", "email", "first_name", "last_name"] |
| 81 | widgets = { |
| @@ -102,10 +108,17 @@ | |
| 108 | user.save() |
| 109 | return user |
| 110 | |
| 111 | |
| 112 | class UserEditForm(forms.ModelForm): |
| 113 | role = forms.ModelChoiceField( |
| 114 | queryset=OrgRole.objects.filter(deleted_at__isnull=True), |
| 115 | required=False, |
| 116 | empty_label="No role", |
| 117 | widget=forms.Select(attrs={"class": tw}), |
| 118 | ) |
| 119 | |
| 120 | class Meta: |
| 121 | model = User |
| 122 | fields = ["email", "first_name", "last_name", "is_active", "is_staff"] |
| 123 | widgets = { |
| 124 | "email": forms.EmailInput(attrs={"class": tw, "placeholder": "[email protected]"}), |
| 125 | |
| 126 | DDED organization/management/__init__.py |
| 127 | DDED organization/management/commands/__init__.py |
| 128 | DDED organization/management/commands/seed_roles.py |
| 129 | DDED organization/migrations/0003_historicalorgrole_orgrole_and_more.py |
No diff available
No diff available
| --- a/organization/management/commands/seed_roles.py | ||
| +++ b/organization/management/commands/seed_roles.py | ||
| @@ -0,0 +1,98 @@ | ||
| 1 | +from django.contrib.auth.models import Permission | |
| 2 | +from django.core.management.base import BaseCommand | |
| 3 | + | |
| 4 | +from organization.models import OrgRole | |
| 5 | + | |
| 6 | +ROLE_DEFINITIONS = { | |
| 7 | + "Admin": { | |
| 8 | + "description": "Full access to all features", | |
| 9 | + "is_default": False, | |
| 10 | + "permissions": "__all__", | |
| 11 | + }, | |
| 12 | + "Manager": { | |
| 13 | + "description": "Manage projects, teams, and members", | |
| 14 | + "is_default": False, | |
| 15 | + "permissions": [ | |
| 16 | + "view_project", | |
| 17 | + "add_project", | |
| 18 | + "change_project", | |
| 19 | + "delete_project", | |
| 20 | + "view_projectteam", | |
| 21 | + "add_projectteam", | |
| 22 | + "change_projectteam", | |
| 23 | + "delete_projectteam", | |
| 24 | + "view_team", | |
| 25 | + "add_team", | |
| 26 | + "change_team", | |
| 27 | + "delete_team", | |
| 28 | + "view_organizationmember", | |
| 29 | + "add_organizationmember", | |
| 30 | + "change_organizationmember", | |
| 31 | + "view_organization", | |
| 32 | + "change_organization", | |
| 33 | + "view_page", | |
| 34 | + "add_page", | |
| 35 | + "change_page", | |
| 36 | + "delete_page", | |
| 37 | + "view_fossilrepository", | |
| 38 | + ], | |
| 39 | + }, | |
| 40 | + "Developer": { | |
| 41 | + "description": "Contribute code, create tickets and wiki pages", | |
| 42 | + "is_default": False, | |
| 43 | + "permissions": [ | |
| 44 | + "view_project", | |
| 45 | + "add_project", | |
| 46 | + "view_team", | |
| 47 | + "view_organizationmember", | |
| 48 | + "view_organization", | |
| 49 | + "view_fossilrepository", | |
| 50 | + "view_page", | |
| 51 | + "add_page", | |
| 52 | + ], | |
| 53 | + }, | |
| 54 | + "Viewer": { | |
| 55 | + "description": "Read-only access to all content", | |
| 56 | + "is_default": True, | |
| 57 | + "permissions": [ | |
| 58 | + "view_project", | |
| 59 | + "view_projectteam", | |
| 60 | + "view_team", | |
| 61 | + "view_organizationmember", | |
| 62 | + "view_organization", | |
| 63 | + "view_fossilrepository", | |
| 64 | + "view_page", | |
| 65 | + ], | |
| 66 | + }, | |
| 67 | +} | |
| 68 | + | |
| 69 | + | |
| 70 | +class Command(BaseCommand): | |
| 71 | + help = "Create default organization roles" | |
| 72 | + | |
| 73 | + def handle(self, *args, **options): | |
| 74 | + for name, config in ROLE_DEFINITIONS.items(): | |
| 75 | + role, created = OrgRole.objects.get_or_create( | |
| 76 | + slug=name.lower(), | |
| 77 | + defaults={ | |
| 78 | + "name": name, | |
| 79 | + "description": config["description"], | |
| 80 | + "is_default": config["is_default"], | |
| 81 | + }, | |
| 82 | + ) | |
| 83 | + | |
| 84 | + if not created: | |
| 85 | + role.description = config["description"] | |
| 86 | + role.is_default = config["is_default"] | |
| 87 | + role.save() | |
| 88 | + | |
| 89 | + if config["permissions"] == "__all__": | |
| 90 | + perms = Permission.objects.filter(content_type__app_label__in=["organization", "projects", "pages", "fossil"]) | |
| 91 | + else: | |
| 92 | + perms = Permission.objects.filter(codename__in=config["permissions"]) | |
| 93 | + | |
| 94 | + role.permissions.set(perms) | |
| 95 | + status = "created" if created else "updated" | |
| 96 | + self.stdout.write(f" {status}: {name} ({role.permissions.count()} permissions)") | |
| 97 | + | |
| 98 | + self.stdout.write(self.style.SUCCESS("Done.")) |
| --- a/organization/management/commands/seed_roles.py | |
| +++ b/organization/management/commands/seed_roles.py | |
| @@ -0,0 +1,98 @@ | |
| --- a/organization/management/commands/seed_roles.py | |
| +++ b/organization/management/commands/seed_roles.py | |
| @@ -0,0 +1,98 @@ | |
| 1 | from django.contrib.auth.models import Permission |
| 2 | from django.core.management.base import BaseCommand |
| 3 | |
| 4 | from organization.models import OrgRole |
| 5 | |
| 6 | ROLE_DEFINITIONS = { |
| 7 | "Admin": { |
| 8 | "description": "Full access to all features", |
| 9 | "is_default": False, |
| 10 | "permissions": "__all__", |
| 11 | }, |
| 12 | "Manager": { |
| 13 | "description": "Manage projects, teams, and members", |
| 14 | "is_default": False, |
| 15 | "permissions": [ |
| 16 | "view_project", |
| 17 | "add_project", |
| 18 | "change_project", |
| 19 | "delete_project", |
| 20 | "view_projectteam", |
| 21 | "add_projectteam", |
| 22 | "change_projectteam", |
| 23 | "delete_projectteam", |
| 24 | "view_team", |
| 25 | "add_team", |
| 26 | "change_team", |
| 27 | "delete_team", |
| 28 | "view_organizationmember", |
| 29 | "add_organizationmember", |
| 30 | "change_organizationmember", |
| 31 | "view_organization", |
| 32 | "change_organization", |
| 33 | "view_page", |
| 34 | "add_page", |
| 35 | "change_page", |
| 36 | "delete_page", |
| 37 | "view_fossilrepository", |
| 38 | ], |
| 39 | }, |
| 40 | "Developer": { |
| 41 | "description": "Contribute code, create tickets and wiki pages", |
| 42 | "is_default": False, |
| 43 | "permissions": [ |
| 44 | "view_project", |
| 45 | "add_project", |
| 46 | "view_team", |
| 47 | "view_organizationmember", |
| 48 | "view_organization", |
| 49 | "view_fossilrepository", |
| 50 | "view_page", |
| 51 | "add_page", |
| 52 | ], |
| 53 | }, |
| 54 | "Viewer": { |
| 55 | "description": "Read-only access to all content", |
| 56 | "is_default": True, |
| 57 | "permissions": [ |
| 58 | "view_project", |
| 59 | "view_projectteam", |
| 60 | "view_team", |
| 61 | "view_organizationmember", |
| 62 | "view_organization", |
| 63 | "view_fossilrepository", |
| 64 | "view_page", |
| 65 | ], |
| 66 | }, |
| 67 | } |
| 68 | |
| 69 | |
| 70 | class Command(BaseCommand): |
| 71 | help = "Create default organization roles" |
| 72 | |
| 73 | def handle(self, *args, **options): |
| 74 | for name, config in ROLE_DEFINITIONS.items(): |
| 75 | role, created = OrgRole.objects.get_or_create( |
| 76 | slug=name.lower(), |
| 77 | defaults={ |
| 78 | "name": name, |
| 79 | "description": config["description"], |
| 80 | "is_default": config["is_default"], |
| 81 | }, |
| 82 | ) |
| 83 | |
| 84 | if not created: |
| 85 | role.description = config["description"] |
| 86 | role.is_default = config["is_default"] |
| 87 | role.save() |
| 88 | |
| 89 | if config["permissions"] == "__all__": |
| 90 | perms = Permission.objects.filter(content_type__app_label__in=["organization", "projects", "pages", "fossil"]) |
| 91 | else: |
| 92 | perms = Permission.objects.filter(codename__in=config["permissions"]) |
| 93 | |
| 94 | role.permissions.set(perms) |
| 95 | status = "created" if created else "updated" |
| 96 | self.stdout.write(f" {status}: {name} ({role.permissions.count()} permissions)") |
| 97 | |
| 98 | self.stdout.write(self.style.SUCCESS("Done.")) |
| --- a/organization/migrations/0003_historicalorgrole_orgrole_and_more.py | ||
| +++ b/organization/migrations/0003_historicalorgrole_orgrole_and_more.py | ||
| @@ -0,0 +1,193 @@ | ||
| 1 | +# Generated by Django 5.2.12 on 2026-04-07 14:26 | |
| 2 | + | |
| 3 | +import uuid | |
| 4 | + | |
| 5 | +import django.db.models.deletion | |
| 6 | +import simple_history.models | |
| 7 | +from django.conf import settings | |
| 8 | +from django.db import migrations, models | |
| 9 | + | |
| 10 | + | |
| 11 | +class Migration(migrations.Migration): | |
| 12 | + dependencies = [ | |
| 13 | + ("auth", "0012_alter_user_first_name_max_length"), | |
| 14 | + ("organization", "0002_historicalteam_team"), | |
| 15 | + migrations.swappable_dependency(settings.AUTH_USER_MODEL), | |
| 16 | + ] | |
| 17 | + | |
| 18 | + operations = [ | |
| 19 | + migrations.CreateModel( | |
| 20 | + name="HistoricalOrgRole", | |
| 21 | + fields=[ | |
| 22 | + ( | |
| 23 | + "id", | |
| 24 | + models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), | |
| 25 | + ), | |
| 26 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 27 | + ("created_at", models.DateTimeField(blank=True, editable=False)), | |
| 28 | + ("updated_at", models.DateTimeField(blank=True, editable=False)), | |
| 29 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 30 | + ( | |
| 31 | + "guid", | |
| 32 | + models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), | |
| 33 | + ), | |
| 34 | + ("name", models.CharField(max_length=200)), | |
| 35 | + ("slug", models.SlugField(max_length=200)), | |
| 36 | + ("description", models.TextField(blank=True, default="")), | |
| 37 | + ( | |
| 38 | + "is_default", | |
| 39 | + models.BooleanField(default=False, help_text="Assigned to new users automatically"), | |
| 40 | + ), | |
| 41 | + ("history_id", models.AutoField(primary_key=True, serialize=False)), | |
| 42 | + ("history_date", models.DateTimeField(db_index=True)), | |
| 43 | + ("history_change_reason", models.CharField(max_length=100, null=True)), | |
| 44 | + ( | |
| 45 | + "history_type", | |
| 46 | + models.CharField( | |
| 47 | + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], | |
| 48 | + max_length=1, | |
| 49 | + ), | |
| 50 | + ), | |
| 51 | + ( | |
| 52 | + "created_by", | |
| 53 | + models.ForeignKey( | |
| 54 | + blank=True, | |
| 55 | + db_constraint=False, | |
| 56 | + null=True, | |
| 57 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 58 | + related_name="+", | |
| 59 | + to=settings.AUTH_USER_MODEL, | |
| 60 | + ), | |
| 61 | + ), | |
| 62 | + ( | |
| 63 | + "deleted_by", | |
| 64 | + models.ForeignKey( | |
| 65 | + blank=True, | |
| 66 | + db_constraint=False, | |
| 67 | + null=True, | |
| 68 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 69 | + related_name="+", | |
| 70 | + to=settings.AUTH_USER_MODEL, | |
| 71 | + ), | |
| 72 | + ), | |
| 73 | + ( | |
| 74 | + "history_user", | |
| 75 | + models.ForeignKey( | |
| 76 | + null=True, | |
| 77 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 78 | + related_name="+", | |
| 79 | + to=settings.AUTH_USER_MODEL, | |
| 80 | + ), | |
| 81 | + ), | |
| 82 | + ( | |
| 83 | + "updated_by", | |
| 84 | + models.ForeignKey( | |
| 85 | + blank=True, | |
| 86 | + db_constraint=False, | |
| 87 | + null=True, | |
| 88 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 89 | + related_name="+", | |
| 90 | + to=settings.AUTH_USER_MODEL, | |
| 91 | + ), | |
| 92 | + ), | |
| 93 | + ], | |
| 94 | + options={ | |
| 95 | + "verbose_name": "historical org role", | |
| 96 | + "verbose_name_plural": "historical org roles", | |
| 97 | + "ordering": ("-history_date", "-history_id"), | |
| 98 | + "get_latest_by": ("history_date", "history_id"), | |
| 99 | + }, | |
| 100 | + bases=(simple_history.models.HistoricalChanges, models.Model), | |
| 101 | + ), | |
| 102 | + migrations.CreateModel( | |
| 103 | + name="OrgRole", | |
| 104 | + fields=[ | |
| 105 | + ( | |
| 106 | + "id", | |
| 107 | + models.BigAutoField( | |
| 108 | + auto_created=True, | |
| 109 | + primary_key=True, | |
| 110 | + serialize=False, | |
| 111 | + verbose_name="ID", | |
| 112 | + ), | |
| 113 | + ), | |
| 114 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 115 | + ("created_at", models.DateTimeField(auto_now_add=True)), | |
| 116 | + ("updated_at", models.DateTimeField(auto_now=True)), | |
| 117 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 118 | + ( | |
| 119 | + "guid", | |
| 120 | + models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True), | |
| 121 | + ), | |
| 122 | + ("name", models.CharField(max_length=200)), | |
| 123 | + ("slug", models.SlugField(max_length=200, unique=True)), | |
| 124 | + ("description", models.TextField(blank=True, default="")), | |
| 125 | + ( | |
| 126 | + "is_default", | |
| 127 | + models.BooleanField(default=False, help_text="Assigned to new users automatically"), | |
| 128 | + ), | |
| 129 | + ( | |
| 130 | + "created_by", | |
| 131 | + models.ForeignKey( | |
| 132 | + blank=True, | |
| 133 | + null=True, | |
| 134 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 135 | + related_name="+", | |
| 136 | + to=settings.AUTH_USER_MODEL, | |
| 137 | + ), | |
| 138 | + ), | |
| 139 | + ( | |
| 140 | + "deleted_by", | |
| 141 | + models.ForeignKey( | |
| 142 | + blank=True, | |
| 143 | + null=True, | |
| 144 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 145 | + related_name="+", | |
| 146 | + to=settings.AUTH_USER_MODEL, | |
| 147 | + ), | |
| 148 | + ), | |
| 149 | + ( | |
| 150 | + "permissions", | |
| 151 | + models.ManyToManyField(blank=True, related_name="org_roles", to="auth.permission"), | |
| 152 | + ), | |
| 153 | + ( | |
| 154 | + "updated_by", | |
| 155 | + models.ForeignKey( | |
| 156 | + blank=True, | |
| 157 | + null=True, | |
| 158 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 159 | + related_name="+", | |
| 160 | + to=settings.AUTH_USER_MODEL, | |
| 161 | + ), | |
| 162 | + ), | |
| 163 | + ], | |
| 164 | + options={ | |
| 165 | + "ordering": ["name"], | |
| 166 | + }, | |
| 167 | + ), | |
| 168 | + migrations.AddField( | |
| 169 | + model_name="historicalorganizationmember", | |
| 170 | + name="role", | |
| 171 | + field=models.ForeignKey( | |
| 172 | + blank=True, | |
| 173 | + db_constraint=False, | |
| 174 | + help_text="Organization role", | |
| 175 | + null=True, | |
| 176 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 177 | + related_name="+", | |
| 178 | + to="organization.orgrole", | |
| 179 | + ), | |
| 180 | + ), | |
| 181 | + migrations.AddField( | |
| 182 | + model_name="organizationmember", | |
| 183 | + name="role", | |
| 184 | + field=models.ForeignKey( | |
| 185 | + blank=True, | |
| 186 | + help_text="Organization role", | |
| 187 | + null=True, | |
| 188 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 189 | + related_name="members", | |
| 190 | + to="organization.orgrole", | |
| 191 | + ), | |
| 192 | + ), | |
| 193 | + ] |
| --- a/organization/migrations/0003_historicalorgrole_orgrole_and_more.py | |
| +++ b/organization/migrations/0003_historicalorgrole_orgrole_and_more.py | |
| @@ -0,0 +1,193 @@ | |
| --- a/organization/migrations/0003_historicalorgrole_orgrole_and_more.py | |
| +++ b/organization/migrations/0003_historicalorgrole_orgrole_and_more.py | |
| @@ -0,0 +1,193 @@ | |
| 1 | # Generated by Django 5.2.12 on 2026-04-07 14:26 |
| 2 | |
| 3 | import uuid |
| 4 | |
| 5 | import django.db.models.deletion |
| 6 | import simple_history.models |
| 7 | from django.conf import settings |
| 8 | from django.db import migrations, models |
| 9 | |
| 10 | |
| 11 | class Migration(migrations.Migration): |
| 12 | dependencies = [ |
| 13 | ("auth", "0012_alter_user_first_name_max_length"), |
| 14 | ("organization", "0002_historicalteam_team"), |
| 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
| 16 | ] |
| 17 | |
| 18 | operations = [ |
| 19 | migrations.CreateModel( |
| 20 | name="HistoricalOrgRole", |
| 21 | fields=[ |
| 22 | ( |
| 23 | "id", |
| 24 | models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), |
| 25 | ), |
| 26 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 27 | ("created_at", models.DateTimeField(blank=True, editable=False)), |
| 28 | ("updated_at", models.DateTimeField(blank=True, editable=False)), |
| 29 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 30 | ( |
| 31 | "guid", |
| 32 | models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), |
| 33 | ), |
| 34 | ("name", models.CharField(max_length=200)), |
| 35 | ("slug", models.SlugField(max_length=200)), |
| 36 | ("description", models.TextField(blank=True, default="")), |
| 37 | ( |
| 38 | "is_default", |
| 39 | models.BooleanField(default=False, help_text="Assigned to new users automatically"), |
| 40 | ), |
| 41 | ("history_id", models.AutoField(primary_key=True, serialize=False)), |
| 42 | ("history_date", models.DateTimeField(db_index=True)), |
| 43 | ("history_change_reason", models.CharField(max_length=100, null=True)), |
| 44 | ( |
| 45 | "history_type", |
| 46 | models.CharField( |
| 47 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], |
| 48 | max_length=1, |
| 49 | ), |
| 50 | ), |
| 51 | ( |
| 52 | "created_by", |
| 53 | models.ForeignKey( |
| 54 | blank=True, |
| 55 | db_constraint=False, |
| 56 | null=True, |
| 57 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 58 | related_name="+", |
| 59 | to=settings.AUTH_USER_MODEL, |
| 60 | ), |
| 61 | ), |
| 62 | ( |
| 63 | "deleted_by", |
| 64 | models.ForeignKey( |
| 65 | blank=True, |
| 66 | db_constraint=False, |
| 67 | null=True, |
| 68 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 69 | related_name="+", |
| 70 | to=settings.AUTH_USER_MODEL, |
| 71 | ), |
| 72 | ), |
| 73 | ( |
| 74 | "history_user", |
| 75 | models.ForeignKey( |
| 76 | null=True, |
| 77 | on_delete=django.db.models.deletion.SET_NULL, |
| 78 | related_name="+", |
| 79 | to=settings.AUTH_USER_MODEL, |
| 80 | ), |
| 81 | ), |
| 82 | ( |
| 83 | "updated_by", |
| 84 | models.ForeignKey( |
| 85 | blank=True, |
| 86 | db_constraint=False, |
| 87 | null=True, |
| 88 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 89 | related_name="+", |
| 90 | to=settings.AUTH_USER_MODEL, |
| 91 | ), |
| 92 | ), |
| 93 | ], |
| 94 | options={ |
| 95 | "verbose_name": "historical org role", |
| 96 | "verbose_name_plural": "historical org roles", |
| 97 | "ordering": ("-history_date", "-history_id"), |
| 98 | "get_latest_by": ("history_date", "history_id"), |
| 99 | }, |
| 100 | bases=(simple_history.models.HistoricalChanges, models.Model), |
| 101 | ), |
| 102 | migrations.CreateModel( |
| 103 | name="OrgRole", |
| 104 | fields=[ |
| 105 | ( |
| 106 | "id", |
| 107 | models.BigAutoField( |
| 108 | auto_created=True, |
| 109 | primary_key=True, |
| 110 | serialize=False, |
| 111 | verbose_name="ID", |
| 112 | ), |
| 113 | ), |
| 114 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 115 | ("created_at", models.DateTimeField(auto_now_add=True)), |
| 116 | ("updated_at", models.DateTimeField(auto_now=True)), |
| 117 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 118 | ( |
| 119 | "guid", |
| 120 | models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True), |
| 121 | ), |
| 122 | ("name", models.CharField(max_length=200)), |
| 123 | ("slug", models.SlugField(max_length=200, unique=True)), |
| 124 | ("description", models.TextField(blank=True, default="")), |
| 125 | ( |
| 126 | "is_default", |
| 127 | models.BooleanField(default=False, help_text="Assigned to new users automatically"), |
| 128 | ), |
| 129 | ( |
| 130 | "created_by", |
| 131 | models.ForeignKey( |
| 132 | blank=True, |
| 133 | null=True, |
| 134 | on_delete=django.db.models.deletion.SET_NULL, |
| 135 | related_name="+", |
| 136 | to=settings.AUTH_USER_MODEL, |
| 137 | ), |
| 138 | ), |
| 139 | ( |
| 140 | "deleted_by", |
| 141 | models.ForeignKey( |
| 142 | blank=True, |
| 143 | null=True, |
| 144 | on_delete=django.db.models.deletion.SET_NULL, |
| 145 | related_name="+", |
| 146 | to=settings.AUTH_USER_MODEL, |
| 147 | ), |
| 148 | ), |
| 149 | ( |
| 150 | "permissions", |
| 151 | models.ManyToManyField(blank=True, related_name="org_roles", to="auth.permission"), |
| 152 | ), |
| 153 | ( |
| 154 | "updated_by", |
| 155 | models.ForeignKey( |
| 156 | blank=True, |
| 157 | null=True, |
| 158 | on_delete=django.db.models.deletion.SET_NULL, |
| 159 | related_name="+", |
| 160 | to=settings.AUTH_USER_MODEL, |
| 161 | ), |
| 162 | ), |
| 163 | ], |
| 164 | options={ |
| 165 | "ordering": ["name"], |
| 166 | }, |
| 167 | ), |
| 168 | migrations.AddField( |
| 169 | model_name="historicalorganizationmember", |
| 170 | name="role", |
| 171 | field=models.ForeignKey( |
| 172 | blank=True, |
| 173 | db_constraint=False, |
| 174 | help_text="Organization role", |
| 175 | null=True, |
| 176 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 177 | related_name="+", |
| 178 | to="organization.orgrole", |
| 179 | ), |
| 180 | ), |
| 181 | migrations.AddField( |
| 182 | model_name="organizationmember", |
| 183 | name="role", |
| 184 | field=models.ForeignKey( |
| 185 | blank=True, |
| 186 | help_text="Organization role", |
| 187 | null=True, |
| 188 | on_delete=django.db.models.deletion.SET_NULL, |
| 189 | related_name="members", |
| 190 | to="organization.orgrole", |
| 191 | ), |
| 192 | ), |
| 193 | ] |
| --- organization/models.py | ||
| +++ organization/models.py | ||
| @@ -12,10 +12,42 @@ | ||
| 12 | 12 | all_objects = models.Manager() |
| 13 | 13 | |
| 14 | 14 | class Meta: |
| 15 | 15 | ordering = ["name"] |
| 16 | 16 | |
| 17 | + | |
| 18 | +class OrgRole(BaseCoreModel): | |
| 19 | + """Predefined organization role with a bundle of permissions.""" | |
| 20 | + | |
| 21 | + is_default = models.BooleanField(default=False, help_text="Assigned to new users automatically") | |
| 22 | + permissions = models.ManyToManyField("auth.Permission", blank=True, related_name="org_roles") | |
| 23 | + | |
| 24 | + objects = ActiveManager() | |
| 25 | + all_objects = models.Manager() | |
| 26 | + | |
| 27 | + class Meta: | |
| 28 | + ordering = ["name"] | |
| 29 | + | |
| 30 | + def __str__(self): | |
| 31 | + return self.name | |
| 32 | + | |
| 33 | + def apply_to_user(self, user): | |
| 34 | + """Sync this role's permissions to a Django user via a group.""" | |
| 35 | + group, _ = Group.objects.get_or_create(name=f"role_{self.slug}") | |
| 36 | + group.permissions.set(self.permissions.all()) | |
| 37 | + | |
| 38 | + # Remove user from all role groups, then add to this one | |
| 39 | + role_groups = Group.objects.filter(name__startswith="role_") | |
| 40 | + user.groups.remove(*role_groups) | |
| 41 | + user.groups.add(group) | |
| 42 | + | |
| 43 | + @staticmethod | |
| 44 | + def remove_role_groups(user): | |
| 45 | + """Remove all role-based groups from a user.""" | |
| 46 | + role_groups = Group.objects.filter(name__startswith="role_") | |
| 47 | + user.groups.remove(*role_groups) | |
| 48 | + | |
| 17 | 49 | |
| 18 | 50 | class Team(BaseCoreModel): |
| 19 | 51 | organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="teams") |
| 20 | 52 | members = models.ManyToManyField("auth.User", blank=True, related_name="teams") |
| 21 | 53 | |
| @@ -28,10 +60,13 @@ | ||
| 28 | 60 | |
| 29 | 61 | class OrganizationMember(Tracking): |
| 30 | 62 | is_active = models.BooleanField(default=True) |
| 31 | 63 | member = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="memberships") |
| 32 | 64 | organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="members") |
| 65 | + role = models.ForeignKey( | |
| 66 | + OrgRole, null=True, blank=True, on_delete=models.SET_NULL, related_name="members", help_text="Organization role" | |
| 67 | + ) | |
| 33 | 68 | groups = models.ManyToManyField(Group, blank=True, related_name="org_memberships") |
| 34 | 69 | |
| 35 | 70 | objects = ActiveManager() |
| 36 | 71 | all_objects = models.Manager() |
| 37 | 72 | |
| 38 | 73 |
| --- organization/models.py | |
| +++ organization/models.py | |
| @@ -12,10 +12,42 @@ | |
| 12 | all_objects = models.Manager() |
| 13 | |
| 14 | class Meta: |
| 15 | ordering = ["name"] |
| 16 | |
| 17 | |
| 18 | class Team(BaseCoreModel): |
| 19 | organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="teams") |
| 20 | members = models.ManyToManyField("auth.User", blank=True, related_name="teams") |
| 21 | |
| @@ -28,10 +60,13 @@ | |
| 28 | |
| 29 | class OrganizationMember(Tracking): |
| 30 | is_active = models.BooleanField(default=True) |
| 31 | member = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="memberships") |
| 32 | organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="members") |
| 33 | groups = models.ManyToManyField(Group, blank=True, related_name="org_memberships") |
| 34 | |
| 35 | objects = ActiveManager() |
| 36 | all_objects = models.Manager() |
| 37 | |
| 38 |
| --- organization/models.py | |
| +++ organization/models.py | |
| @@ -12,10 +12,42 @@ | |
| 12 | all_objects = models.Manager() |
| 13 | |
| 14 | class Meta: |
| 15 | ordering = ["name"] |
| 16 | |
| 17 | |
| 18 | class OrgRole(BaseCoreModel): |
| 19 | """Predefined organization role with a bundle of permissions.""" |
| 20 | |
| 21 | is_default = models.BooleanField(default=False, help_text="Assigned to new users automatically") |
| 22 | permissions = models.ManyToManyField("auth.Permission", blank=True, related_name="org_roles") |
| 23 | |
| 24 | objects = ActiveManager() |
| 25 | all_objects = models.Manager() |
| 26 | |
| 27 | class Meta: |
| 28 | ordering = ["name"] |
| 29 | |
| 30 | def __str__(self): |
| 31 | return self.name |
| 32 | |
| 33 | def apply_to_user(self, user): |
| 34 | """Sync this role's permissions to a Django user via a group.""" |
| 35 | group, _ = Group.objects.get_or_create(name=f"role_{self.slug}") |
| 36 | group.permissions.set(self.permissions.all()) |
| 37 | |
| 38 | # Remove user from all role groups, then add to this one |
| 39 | role_groups = Group.objects.filter(name__startswith="role_") |
| 40 | user.groups.remove(*role_groups) |
| 41 | user.groups.add(group) |
| 42 | |
| 43 | @staticmethod |
| 44 | def remove_role_groups(user): |
| 45 | """Remove all role-based groups from a user.""" |
| 46 | role_groups = Group.objects.filter(name__startswith="role_") |
| 47 | user.groups.remove(*role_groups) |
| 48 | |
| 49 | |
| 50 | class Team(BaseCoreModel): |
| 51 | organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="teams") |
| 52 | members = models.ManyToManyField("auth.User", blank=True, related_name="teams") |
| 53 | |
| @@ -28,10 +60,13 @@ | |
| 60 | |
| 61 | class OrganizationMember(Tracking): |
| 62 | is_active = models.BooleanField(default=True) |
| 63 | member = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="memberships") |
| 64 | organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="members") |
| 65 | role = models.ForeignKey( |
| 66 | OrgRole, null=True, blank=True, on_delete=models.SET_NULL, related_name="members", help_text="Organization role" |
| 67 | ) |
| 68 | groups = models.ManyToManyField(Group, blank=True, related_name="org_memberships") |
| 69 | |
| 70 | objects = ActiveManager() |
| 71 | all_objects = models.Manager() |
| 72 | |
| 73 |
| --- organization/urls.py | ||
| +++ organization/urls.py | ||
| @@ -14,10 +14,14 @@ | ||
| 14 | 14 | path("members/create/", views.user_create, name="user_create"), |
| 15 | 15 | path("members/<str:username>/", views.user_detail, name="user_detail"), |
| 16 | 16 | path("members/<str:username>/edit/", views.user_edit, name="user_edit"), |
| 17 | 17 | path("members/<str:username>/password/", views.user_password, name="user_password"), |
| 18 | 18 | path("members/<str:username>/remove/", views.member_remove, name="member_remove"), |
| 19 | + # Roles | |
| 20 | + path("roles/", views.role_list, name="role_list"), | |
| 21 | + path("roles/initialize/", views.role_initialize, name="role_initialize"), | |
| 22 | + path("roles/<slug:slug>/", views.role_detail, name="role_detail"), | |
| 19 | 23 | # Teams |
| 20 | 24 | path("teams/", views.team_list, name="team_list"), |
| 21 | 25 | path("teams/create/", views.team_create, name="team_create"), |
| 22 | 26 | path("teams/<slug:slug>/", views.team_detail, name="team_detail"), |
| 23 | 27 | path("teams/<slug:slug>/edit/", views.team_update, name="team_update"), |
| 24 | 28 |
| --- organization/urls.py | |
| +++ organization/urls.py | |
| @@ -14,10 +14,14 @@ | |
| 14 | path("members/create/", views.user_create, name="user_create"), |
| 15 | path("members/<str:username>/", views.user_detail, name="user_detail"), |
| 16 | path("members/<str:username>/edit/", views.user_edit, name="user_edit"), |
| 17 | path("members/<str:username>/password/", views.user_password, name="user_password"), |
| 18 | path("members/<str:username>/remove/", views.member_remove, name="member_remove"), |
| 19 | # Teams |
| 20 | path("teams/", views.team_list, name="team_list"), |
| 21 | path("teams/create/", views.team_create, name="team_create"), |
| 22 | path("teams/<slug:slug>/", views.team_detail, name="team_detail"), |
| 23 | path("teams/<slug:slug>/edit/", views.team_update, name="team_update"), |
| 24 |
| --- organization/urls.py | |
| +++ organization/urls.py | |
| @@ -14,10 +14,14 @@ | |
| 14 | path("members/create/", views.user_create, name="user_create"), |
| 15 | path("members/<str:username>/", views.user_detail, name="user_detail"), |
| 16 | path("members/<str:username>/edit/", views.user_edit, name="user_edit"), |
| 17 | path("members/<str:username>/password/", views.user_password, name="user_password"), |
| 18 | path("members/<str:username>/remove/", views.member_remove, name="member_remove"), |
| 19 | # Roles |
| 20 | path("roles/", views.role_list, name="role_list"), |
| 21 | path("roles/initialize/", views.role_initialize, name="role_initialize"), |
| 22 | path("roles/<slug:slug>/", views.role_detail, name="role_detail"), |
| 23 | # Teams |
| 24 | path("teams/", views.team_list, name="team_list"), |
| 25 | path("teams/create/", views.team_create, name="team_create"), |
| 26 | path("teams/<slug:slug>/", views.team_detail, name="team_detail"), |
| 27 | path("teams/<slug:slug>/edit/", views.team_update, name="team_update"), |
| 28 |
| --- organization/views.py | ||
| +++ organization/views.py | ||
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | from django.contrib import messages |
| 2 | 2 | from django.contrib.auth.decorators import login_required |
| 3 | 3 | from django.contrib.auth.models import User |
| 4 | +from django.db import models | |
| 4 | 5 | from django.http import HttpResponse |
| 5 | 6 | from django.shortcuts import get_object_or_404, redirect, render |
| 6 | 7 | |
| 7 | 8 | from core.permissions import P |
| 8 | 9 | |
| @@ -13,11 +14,11 @@ | ||
| 13 | 14 | TeamMemberAddForm, |
| 14 | 15 | UserCreateForm, |
| 15 | 16 | UserEditForm, |
| 16 | 17 | UserPasswordForm, |
| 17 | 18 | ) |
| 18 | -from .models import Organization, OrganizationMember, Team | |
| 19 | +from .models import Organization, OrganizationMember, OrgRole, Team | |
| 19 | 20 | |
| 20 | 21 | |
| 21 | 22 | def get_org(): |
| 22 | 23 | return Organization.objects.first() |
| 23 | 24 | |
| @@ -56,11 +57,11 @@ | ||
| 56 | 57 | |
| 57 | 58 | @login_required |
| 58 | 59 | def member_list(request): |
| 59 | 60 | P.ORGANIZATION_MEMBER_VIEW.check(request.user) |
| 60 | 61 | org = get_org() |
| 61 | - members = OrganizationMember.objects.filter(organization=org).select_related("member") | |
| 62 | + members = OrganizationMember.objects.filter(organization=org).select_related("member", "role") | |
| 62 | 63 | |
| 63 | 64 | search = request.GET.get("search", "").strip() |
| 64 | 65 | if search: |
| 65 | 66 | members = members.filter(member__username__icontains=search) |
| 66 | 67 | |
| @@ -242,11 +243,14 @@ | ||
| 242 | 243 | |
| 243 | 244 | if request.method == "POST": |
| 244 | 245 | form = UserCreateForm(request.POST) |
| 245 | 246 | if form.is_valid(): |
| 246 | 247 | user = form.save() |
| 247 | - OrganizationMember.objects.create(member=user, organization=org, created_by=request.user) | |
| 248 | + role = form.cleaned_data.get("role") | |
| 249 | + OrganizationMember.objects.create(member=user, organization=org, role=role, created_by=request.user) | |
| 250 | + if role: | |
| 251 | + role.apply_to_user(user) | |
| 248 | 252 | messages.success(request, f'User "{user.username}" created and added as member.') |
| 249 | 253 | return redirect("organization:members") |
| 250 | 254 | else: |
| 251 | 255 | form = UserCreateForm() |
| 252 | 256 | |
| @@ -256,11 +260,13 @@ | ||
| 256 | 260 | @login_required |
| 257 | 261 | def user_detail(request, username): |
| 258 | 262 | P.ORGANIZATION_MEMBER_VIEW.check(request.user) |
| 259 | 263 | org = get_org() |
| 260 | 264 | target_user = get_object_or_404(User, username=username) |
| 261 | - membership = OrganizationMember.objects.filter(member=target_user, organization=org, deleted_at__isnull=True).first() | |
| 265 | + membership = ( | |
| 266 | + OrganizationMember.objects.filter(member=target_user, organization=org, deleted_at__isnull=True).select_related("role").first() | |
| 267 | + ) | |
| 262 | 268 | user_teams = Team.objects.filter(members=target_user, organization=org, deleted_at__isnull=True) |
| 263 | 269 | |
| 264 | 270 | from fossil.user_keys import UserSSHKey |
| 265 | 271 | |
| 266 | 272 | ssh_keys = UserSSHKey.objects.filter(user=target_user) |
| @@ -282,21 +288,37 @@ | ||
| 282 | 288 | |
| 283 | 289 | |
| 284 | 290 | @login_required |
| 285 | 291 | def user_edit(request, username): |
| 286 | 292 | _check_user_management_permission(request) |
| 293 | + org = get_org() | |
| 287 | 294 | target_user = get_object_or_404(User, username=username) |
| 288 | 295 | editing_self = request.user.pk == target_user.pk |
| 296 | + membership = ( | |
| 297 | + OrganizationMember.objects.filter(member=target_user, organization=org, deleted_at__isnull=True).select_related("role").first() | |
| 298 | + ) | |
| 289 | 299 | |
| 290 | 300 | if request.method == "POST": |
| 291 | 301 | form = UserEditForm(request.POST, instance=target_user, editing_self=editing_self) |
| 292 | 302 | if form.is_valid(): |
| 293 | 303 | form.save() |
| 304 | + role = form.cleaned_data.get("role") | |
| 305 | + if membership: | |
| 306 | + membership.role = role | |
| 307 | + membership.updated_by = request.user | |
| 308 | + membership.save() | |
| 309 | + if role: | |
| 310 | + role.apply_to_user(target_user) | |
| 311 | + else: | |
| 312 | + OrgRole.remove_role_groups(target_user) | |
| 294 | 313 | messages.success(request, f'User "{target_user.username}" updated.') |
| 295 | 314 | return redirect("organization:members") |
| 296 | 315 | else: |
| 297 | - form = UserEditForm(instance=target_user, editing_self=editing_self) | |
| 316 | + initial = {} | |
| 317 | + if membership and membership.role: | |
| 318 | + initial["role"] = membership.role.pk | |
| 319 | + form = UserEditForm(instance=target_user, editing_self=editing_self, initial=initial) | |
| 298 | 320 | |
| 299 | 321 | return render( |
| 300 | 322 | request, |
| 301 | 323 | "organization/user_form.html", |
| 302 | 324 | {"form": form, "title": f"Edit {target_user.username}", "edit_user": target_user}, |
| @@ -321,5 +343,59 @@ | ||
| 321 | 343 | return redirect("organization:user_detail", username=target_user.username) |
| 322 | 344 | else: |
| 323 | 345 | form = UserPasswordForm() |
| 324 | 346 | |
| 325 | 347 | return render(request, "organization/user_password.html", {"form": form, "target_user": target_user}) |
| 348 | + | |
| 349 | + | |
| 350 | +# --- Roles --- | |
| 351 | + | |
| 352 | + | |
| 353 | +@login_required | |
| 354 | +def role_list(request): | |
| 355 | + P.ORGANIZATION_VIEW.check(request.user) | |
| 356 | + roles = OrgRole.objects.annotate( | |
| 357 | + member_count=models.Count("members", filter=models.Q(members__deleted_at__isnull=True)), | |
| 358 | + permission_count=models.Count("permissions"), | |
| 359 | + ) | |
| 360 | + return render(request, "organization/role_list.html", {"roles": roles}) | |
| 361 | + | |
| 362 | + | |
| 363 | +@login_required | |
| 364 | +def role_detail(request, slug): | |
| 365 | + P.ORGANIZATION_VIEW.check(request.user) | |
| 366 | + role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True) | |
| 367 | + role_permissions = role.permissions.select_related("content_type").order_by("content_type__app_label", "codename") | |
| 368 | + | |
| 369 | + # Group permissions by app label | |
| 370 | + grouped = {} | |
| 371 | + app_labels = { | |
| 372 | + "organization": "Organization", | |
| 373 | + "projects": "Projects", | |
| 374 | + "pages": "Pages", | |
| 375 | + "fossil": "Fossil", | |
| 376 | + } | |
| 377 | + for perm in role_permissions: | |
| 378 | + app = perm.content_type.app_label | |
| 379 | + label = app_labels.get(app, app.title()) | |
| 380 | + grouped.setdefault(label, []).append(perm) | |
| 381 | + | |
| 382 | + role_members = OrganizationMember.objects.filter(role=role, deleted_at__isnull=True).select_related("member") | |
| 383 | + | |
| 384 | + return render( | |
| 385 | + request, | |
| 386 | + "organization/role_detail.html", | |
| 387 | + {"role": role, "grouped_permissions": grouped, "role_members": role_members}, | |
| 388 | + ) | |
| 389 | + | |
| 390 | + | |
| 391 | +@login_required | |
| 392 | +def role_initialize(request): | |
| 393 | + P.ORGANIZATION_CHANGE.check(request.user) | |
| 394 | + | |
| 395 | + if request.method == "POST": | |
| 396 | + from django.core.management import call_command | |
| 397 | + | |
| 398 | + call_command("seed_roles") | |
| 399 | + messages.success(request, "Roles initialized successfully.") | |
| 400 | + | |
| 401 | + return redirect("organization:role_list") | |
| 326 | 402 |
| --- organization/views.py | |
| +++ organization/views.py | |
| @@ -1,8 +1,9 @@ | |
| 1 | from django.contrib import messages |
| 2 | from django.contrib.auth.decorators import login_required |
| 3 | from django.contrib.auth.models import User |
| 4 | from django.http import HttpResponse |
| 5 | from django.shortcuts import get_object_or_404, redirect, render |
| 6 | |
| 7 | from core.permissions import P |
| 8 | |
| @@ -13,11 +14,11 @@ | |
| 13 | TeamMemberAddForm, |
| 14 | UserCreateForm, |
| 15 | UserEditForm, |
| 16 | UserPasswordForm, |
| 17 | ) |
| 18 | from .models import Organization, OrganizationMember, Team |
| 19 | |
| 20 | |
| 21 | def get_org(): |
| 22 | return Organization.objects.first() |
| 23 | |
| @@ -56,11 +57,11 @@ | |
| 56 | |
| 57 | @login_required |
| 58 | def member_list(request): |
| 59 | P.ORGANIZATION_MEMBER_VIEW.check(request.user) |
| 60 | org = get_org() |
| 61 | members = OrganizationMember.objects.filter(organization=org).select_related("member") |
| 62 | |
| 63 | search = request.GET.get("search", "").strip() |
| 64 | if search: |
| 65 | members = members.filter(member__username__icontains=search) |
| 66 | |
| @@ -242,11 +243,14 @@ | |
| 242 | |
| 243 | if request.method == "POST": |
| 244 | form = UserCreateForm(request.POST) |
| 245 | if form.is_valid(): |
| 246 | user = form.save() |
| 247 | OrganizationMember.objects.create(member=user, organization=org, created_by=request.user) |
| 248 | messages.success(request, f'User "{user.username}" created and added as member.') |
| 249 | return redirect("organization:members") |
| 250 | else: |
| 251 | form = UserCreateForm() |
| 252 | |
| @@ -256,11 +260,13 @@ | |
| 256 | @login_required |
| 257 | def user_detail(request, username): |
| 258 | P.ORGANIZATION_MEMBER_VIEW.check(request.user) |
| 259 | org = get_org() |
| 260 | target_user = get_object_or_404(User, username=username) |
| 261 | membership = OrganizationMember.objects.filter(member=target_user, organization=org, deleted_at__isnull=True).first() |
| 262 | user_teams = Team.objects.filter(members=target_user, organization=org, deleted_at__isnull=True) |
| 263 | |
| 264 | from fossil.user_keys import UserSSHKey |
| 265 | |
| 266 | ssh_keys = UserSSHKey.objects.filter(user=target_user) |
| @@ -282,21 +288,37 @@ | |
| 282 | |
| 283 | |
| 284 | @login_required |
| 285 | def user_edit(request, username): |
| 286 | _check_user_management_permission(request) |
| 287 | target_user = get_object_or_404(User, username=username) |
| 288 | editing_self = request.user.pk == target_user.pk |
| 289 | |
| 290 | if request.method == "POST": |
| 291 | form = UserEditForm(request.POST, instance=target_user, editing_self=editing_self) |
| 292 | if form.is_valid(): |
| 293 | form.save() |
| 294 | messages.success(request, f'User "{target_user.username}" updated.') |
| 295 | return redirect("organization:members") |
| 296 | else: |
| 297 | form = UserEditForm(instance=target_user, editing_self=editing_self) |
| 298 | |
| 299 | return render( |
| 300 | request, |
| 301 | "organization/user_form.html", |
| 302 | {"form": form, "title": f"Edit {target_user.username}", "edit_user": target_user}, |
| @@ -321,5 +343,59 @@ | |
| 321 | return redirect("organization:user_detail", username=target_user.username) |
| 322 | else: |
| 323 | form = UserPasswordForm() |
| 324 | |
| 325 | return render(request, "organization/user_password.html", {"form": form, "target_user": target_user}) |
| 326 |
| --- organization/views.py | |
| +++ organization/views.py | |
| @@ -1,8 +1,9 @@ | |
| 1 | from django.contrib import messages |
| 2 | from django.contrib.auth.decorators import login_required |
| 3 | from django.contrib.auth.models import User |
| 4 | from django.db import models |
| 5 | from django.http import HttpResponse |
| 6 | from django.shortcuts import get_object_or_404, redirect, render |
| 7 | |
| 8 | from core.permissions import P |
| 9 | |
| @@ -13,11 +14,11 @@ | |
| 14 | TeamMemberAddForm, |
| 15 | UserCreateForm, |
| 16 | UserEditForm, |
| 17 | UserPasswordForm, |
| 18 | ) |
| 19 | from .models import Organization, OrganizationMember, OrgRole, Team |
| 20 | |
| 21 | |
| 22 | def get_org(): |
| 23 | return Organization.objects.first() |
| 24 | |
| @@ -56,11 +57,11 @@ | |
| 57 | |
| 58 | @login_required |
| 59 | def member_list(request): |
| 60 | P.ORGANIZATION_MEMBER_VIEW.check(request.user) |
| 61 | org = get_org() |
| 62 | members = OrganizationMember.objects.filter(organization=org).select_related("member", "role") |
| 63 | |
| 64 | search = request.GET.get("search", "").strip() |
| 65 | if search: |
| 66 | members = members.filter(member__username__icontains=search) |
| 67 | |
| @@ -242,11 +243,14 @@ | |
| 243 | |
| 244 | if request.method == "POST": |
| 245 | form = UserCreateForm(request.POST) |
| 246 | if form.is_valid(): |
| 247 | user = form.save() |
| 248 | role = form.cleaned_data.get("role") |
| 249 | OrganizationMember.objects.create(member=user, organization=org, role=role, created_by=request.user) |
| 250 | if role: |
| 251 | role.apply_to_user(user) |
| 252 | messages.success(request, f'User "{user.username}" created and added as member.') |
| 253 | return redirect("organization:members") |
| 254 | else: |
| 255 | form = UserCreateForm() |
| 256 | |
| @@ -256,11 +260,13 @@ | |
| 260 | @login_required |
| 261 | def user_detail(request, username): |
| 262 | P.ORGANIZATION_MEMBER_VIEW.check(request.user) |
| 263 | org = get_org() |
| 264 | target_user = get_object_or_404(User, username=username) |
| 265 | membership = ( |
| 266 | OrganizationMember.objects.filter(member=target_user, organization=org, deleted_at__isnull=True).select_related("role").first() |
| 267 | ) |
| 268 | user_teams = Team.objects.filter(members=target_user, organization=org, deleted_at__isnull=True) |
| 269 | |
| 270 | from fossil.user_keys import UserSSHKey |
| 271 | |
| 272 | ssh_keys = UserSSHKey.objects.filter(user=target_user) |
| @@ -282,21 +288,37 @@ | |
| 288 | |
| 289 | |
| 290 | @login_required |
| 291 | def user_edit(request, username): |
| 292 | _check_user_management_permission(request) |
| 293 | org = get_org() |
| 294 | target_user = get_object_or_404(User, username=username) |
| 295 | editing_self = request.user.pk == target_user.pk |
| 296 | membership = ( |
| 297 | OrganizationMember.objects.filter(member=target_user, organization=org, deleted_at__isnull=True).select_related("role").first() |
| 298 | ) |
| 299 | |
| 300 | if request.method == "POST": |
| 301 | form = UserEditForm(request.POST, instance=target_user, editing_self=editing_self) |
| 302 | if form.is_valid(): |
| 303 | form.save() |
| 304 | role = form.cleaned_data.get("role") |
| 305 | if membership: |
| 306 | membership.role = role |
| 307 | membership.updated_by = request.user |
| 308 | membership.save() |
| 309 | if role: |
| 310 | role.apply_to_user(target_user) |
| 311 | else: |
| 312 | OrgRole.remove_role_groups(target_user) |
| 313 | messages.success(request, f'User "{target_user.username}" updated.') |
| 314 | return redirect("organization:members") |
| 315 | else: |
| 316 | initial = {} |
| 317 | if membership and membership.role: |
| 318 | initial["role"] = membership.role.pk |
| 319 | form = UserEditForm(instance=target_user, editing_self=editing_self, initial=initial) |
| 320 | |
| 321 | return render( |
| 322 | request, |
| 323 | "organization/user_form.html", |
| 324 | {"form": form, "title": f"Edit {target_user.username}", "edit_user": target_user}, |
| @@ -321,5 +343,59 @@ | |
| 343 | return redirect("organization:user_detail", username=target_user.username) |
| 344 | else: |
| 345 | form = UserPasswordForm() |
| 346 | |
| 347 | return render(request, "organization/user_password.html", {"form": form, "target_user": target_user}) |
| 348 | |
| 349 | |
| 350 | # --- Roles --- |
| 351 | |
| 352 | |
| 353 | @login_required |
| 354 | def role_list(request): |
| 355 | P.ORGANIZATION_VIEW.check(request.user) |
| 356 | roles = OrgRole.objects.annotate( |
| 357 | member_count=models.Count("members", filter=models.Q(members__deleted_at__isnull=True)), |
| 358 | permission_count=models.Count("permissions"), |
| 359 | ) |
| 360 | return render(request, "organization/role_list.html", {"roles": roles}) |
| 361 | |
| 362 | |
| 363 | @login_required |
| 364 | def role_detail(request, slug): |
| 365 | P.ORGANIZATION_VIEW.check(request.user) |
| 366 | role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True) |
| 367 | role_permissions = role.permissions.select_related("content_type").order_by("content_type__app_label", "codename") |
| 368 | |
| 369 | # Group permissions by app label |
| 370 | grouped = {} |
| 371 | app_labels = { |
| 372 | "organization": "Organization", |
| 373 | "projects": "Projects", |
| 374 | "pages": "Pages", |
| 375 | "fossil": "Fossil", |
| 376 | } |
| 377 | for perm in role_permissions: |
| 378 | app = perm.content_type.app_label |
| 379 | label = app_labels.get(app, app.title()) |
| 380 | grouped.setdefault(label, []).append(perm) |
| 381 | |
| 382 | role_members = OrganizationMember.objects.filter(role=role, deleted_at__isnull=True).select_related("member") |
| 383 | |
| 384 | return render( |
| 385 | request, |
| 386 | "organization/role_detail.html", |
| 387 | {"role": role, "grouped_permissions": grouped, "role_members": role_members}, |
| 388 | ) |
| 389 | |
| 390 | |
| 391 | @login_required |
| 392 | def role_initialize(request): |
| 393 | P.ORGANIZATION_CHANGE.check(request.user) |
| 394 | |
| 395 | if request.method == "POST": |
| 396 | from django.core.management import call_command |
| 397 | |
| 398 | call_command("seed_roles") |
| 399 | messages.success(request, "Roles initialized successfully.") |
| 400 | |
| 401 | return redirect("organization:role_list") |
| 402 |
| --- projects/admin.py | ||
| +++ projects/admin.py | ||
| @@ -1,10 +1,16 @@ | ||
| 1 | 1 | from django.contrib import admin |
| 2 | 2 | |
| 3 | 3 | from core.admin import BaseCoreAdmin |
| 4 | 4 | |
| 5 | -from .models import Project, ProjectTeam | |
| 5 | +from .models import Project, ProjectGroup, ProjectTeam | |
| 6 | + | |
| 7 | + | |
| 8 | +@admin.register(ProjectGroup) | |
| 9 | +class ProjectGroupAdmin(BaseCoreAdmin): | |
| 10 | + list_display = ("name", "slug", "created_at") | |
| 11 | + search_fields = ("name", "slug") | |
| 6 | 12 | |
| 7 | 13 | |
| 8 | 14 | class ProjectTeamInline(admin.TabularInline): |
| 9 | 15 | model = ProjectTeam |
| 10 | 16 | extra = 0 |
| @@ -11,12 +17,12 @@ | ||
| 11 | 17 | raw_id_fields = ("team",) |
| 12 | 18 | |
| 13 | 19 | |
| 14 | 20 | @admin.register(Project) |
| 15 | 21 | class ProjectAdmin(BaseCoreAdmin): |
| 16 | - list_display = ("name", "slug", "visibility", "created_at", "created_by") | |
| 17 | - list_filter = ("visibility", "created_at") | |
| 22 | + list_display = ("name", "slug", "group", "visibility", "created_at", "created_by") | |
| 23 | + list_filter = ("visibility", "group", "created_at") | |
| 18 | 24 | search_fields = ("name", "slug", "description") |
| 19 | 25 | inlines = [ProjectTeamInline] |
| 20 | 26 | |
| 21 | 27 | |
| 22 | 28 | @admin.register(ProjectTeam) |
| 23 | 29 |
| --- projects/admin.py | |
| +++ projects/admin.py | |
| @@ -1,10 +1,16 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import Project, ProjectTeam |
| 6 | |
| 7 | |
| 8 | class ProjectTeamInline(admin.TabularInline): |
| 9 | model = ProjectTeam |
| 10 | extra = 0 |
| @@ -11,12 +17,12 @@ | |
| 11 | raw_id_fields = ("team",) |
| 12 | |
| 13 | |
| 14 | @admin.register(Project) |
| 15 | class ProjectAdmin(BaseCoreAdmin): |
| 16 | list_display = ("name", "slug", "visibility", "created_at", "created_by") |
| 17 | list_filter = ("visibility", "created_at") |
| 18 | search_fields = ("name", "slug", "description") |
| 19 | inlines = [ProjectTeamInline] |
| 20 | |
| 21 | |
| 22 | @admin.register(ProjectTeam) |
| 23 |
| --- projects/admin.py | |
| +++ projects/admin.py | |
| @@ -1,10 +1,16 @@ | |
| 1 | from django.contrib import admin |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import Project, ProjectGroup, ProjectTeam |
| 6 | |
| 7 | |
| 8 | @admin.register(ProjectGroup) |
| 9 | class ProjectGroupAdmin(BaseCoreAdmin): |
| 10 | list_display = ("name", "slug", "created_at") |
| 11 | search_fields = ("name", "slug") |
| 12 | |
| 13 | |
| 14 | class ProjectTeamInline(admin.TabularInline): |
| 15 | model = ProjectTeam |
| 16 | extra = 0 |
| @@ -11,12 +17,12 @@ | |
| 17 | raw_id_fields = ("team",) |
| 18 | |
| 19 | |
| 20 | @admin.register(Project) |
| 21 | class ProjectAdmin(BaseCoreAdmin): |
| 22 | list_display = ("name", "slug", "group", "visibility", "created_at", "created_by") |
| 23 | list_filter = ("visibility", "group", "created_at") |
| 24 | search_fields = ("name", "slug", "description") |
| 25 | inlines = [ProjectTeamInline] |
| 26 | |
| 27 | |
| 28 | @admin.register(ProjectTeam) |
| 29 |
| --- projects/forms.py | ||
| +++ projects/forms.py | ||
| @@ -1,18 +1,28 @@ | ||
| 1 | 1 | from django import forms |
| 2 | 2 | |
| 3 | 3 | from organization.models import Team |
| 4 | 4 | |
| 5 | -from .models import Project, ProjectTeam | |
| 5 | +from .models import Project, ProjectGroup, ProjectTeam | |
| 6 | 6 | |
| 7 | 7 | tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 8 | 8 | |
| 9 | 9 | REPO_SOURCE_CHOICES = [ |
| 10 | 10 | ("empty", "Create empty repository"), |
| 11 | 11 | ("fossil_url", "Clone from Fossil URL"), |
| 12 | 12 | ] |
| 13 | 13 | |
| 14 | + | |
| 15 | +class ProjectGroupForm(forms.ModelForm): | |
| 16 | + class Meta: | |
| 17 | + model = ProjectGroup | |
| 18 | + fields = ["name", "description"] | |
| 19 | + widgets = { | |
| 20 | + "name": forms.TextInput(attrs={"class": tw, "placeholder": "Group name"}), | |
| 21 | + "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description (optional)"}), | |
| 22 | + } | |
| 23 | + | |
| 14 | 24 | |
| 15 | 25 | class ProjectForm(forms.ModelForm): |
| 16 | 26 | repo_source = forms.ChoiceField( |
| 17 | 27 | choices=REPO_SOURCE_CHOICES, |
| 18 | 28 | initial="empty", |
| @@ -25,15 +35,16 @@ | ||
| 25 | 35 | help_text="Fossil repository URL to clone from", |
| 26 | 36 | ) |
| 27 | 37 | |
| 28 | 38 | class Meta: |
| 29 | 39 | model = Project |
| 30 | - fields = ["name", "description", "visibility"] | |
| 40 | + fields = ["name", "description", "visibility", "group"] | |
| 31 | 41 | widgets = { |
| 32 | 42 | "name": forms.TextInput(attrs={"class": tw, "placeholder": "Project name"}), |
| 33 | 43 | "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}), |
| 34 | 44 | "visibility": forms.Select(attrs={"class": tw}), |
| 45 | + "group": forms.Select(attrs={"class": tw}), | |
| 35 | 46 | } |
| 36 | 47 | |
| 37 | 48 | def clean(self): |
| 38 | 49 | cleaned = super().clean() |
| 39 | 50 | repo_source = cleaned.get("repo_source", "empty") |
| 40 | 51 | |
| 41 | 52 | ADDED projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py |
| --- projects/forms.py | |
| +++ projects/forms.py | |
| @@ -1,18 +1,28 @@ | |
| 1 | from django import forms |
| 2 | |
| 3 | from organization.models import Team |
| 4 | |
| 5 | from .models import Project, ProjectTeam |
| 6 | |
| 7 | tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 8 | |
| 9 | REPO_SOURCE_CHOICES = [ |
| 10 | ("empty", "Create empty repository"), |
| 11 | ("fossil_url", "Clone from Fossil URL"), |
| 12 | ] |
| 13 | |
| 14 | |
| 15 | class ProjectForm(forms.ModelForm): |
| 16 | repo_source = forms.ChoiceField( |
| 17 | choices=REPO_SOURCE_CHOICES, |
| 18 | initial="empty", |
| @@ -25,15 +35,16 @@ | |
| 25 | help_text="Fossil repository URL to clone from", |
| 26 | ) |
| 27 | |
| 28 | class Meta: |
| 29 | model = Project |
| 30 | fields = ["name", "description", "visibility"] |
| 31 | widgets = { |
| 32 | "name": forms.TextInput(attrs={"class": tw, "placeholder": "Project name"}), |
| 33 | "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}), |
| 34 | "visibility": forms.Select(attrs={"class": tw}), |
| 35 | } |
| 36 | |
| 37 | def clean(self): |
| 38 | cleaned = super().clean() |
| 39 | repo_source = cleaned.get("repo_source", "empty") |
| 40 | |
| 41 | DDED projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py |
| --- projects/forms.py | |
| +++ projects/forms.py | |
| @@ -1,18 +1,28 @@ | |
| 1 | from django import forms |
| 2 | |
| 3 | from organization.models import Team |
| 4 | |
| 5 | from .models import Project, ProjectGroup, ProjectTeam |
| 6 | |
| 7 | tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 8 | |
| 9 | REPO_SOURCE_CHOICES = [ |
| 10 | ("empty", "Create empty repository"), |
| 11 | ("fossil_url", "Clone from Fossil URL"), |
| 12 | ] |
| 13 | |
| 14 | |
| 15 | class ProjectGroupForm(forms.ModelForm): |
| 16 | class Meta: |
| 17 | model = ProjectGroup |
| 18 | fields = ["name", "description"] |
| 19 | widgets = { |
| 20 | "name": forms.TextInput(attrs={"class": tw, "placeholder": "Group name"}), |
| 21 | "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description (optional)"}), |
| 22 | } |
| 23 | |
| 24 | |
| 25 | class ProjectForm(forms.ModelForm): |
| 26 | repo_source = forms.ChoiceField( |
| 27 | choices=REPO_SOURCE_CHOICES, |
| 28 | initial="empty", |
| @@ -25,15 +35,16 @@ | |
| 35 | help_text="Fossil repository URL to clone from", |
| 36 | ) |
| 37 | |
| 38 | class Meta: |
| 39 | model = Project |
| 40 | fields = ["name", "description", "visibility", "group"] |
| 41 | widgets = { |
| 42 | "name": forms.TextInput(attrs={"class": tw, "placeholder": "Project name"}), |
| 43 | "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}), |
| 44 | "visibility": forms.Select(attrs={"class": tw}), |
| 45 | "group": forms.Select(attrs={"class": tw}), |
| 46 | } |
| 47 | |
| 48 | def clean(self): |
| 49 | cleaned = super().clean() |
| 50 | repo_source = cleaned.get("repo_source", "empty") |
| 51 | |
| 52 | DDED projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py |
| --- a/projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py | ||
| +++ b/projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py | ||
| @@ -0,0 +1,180 @@ | ||
| 1 | +# Generated by Django 5.2.12 on 2026-04-07 14:24 | |
| 2 | + | |
| 3 | +import uuid | |
| 4 | + | |
| 5 | +import django.db.models.deletion | |
| 6 | +import simple_history.models | |
| 7 | +from django.conf import settings | |
| 8 | +from django.db import migrations, models | |
| 9 | + | |
| 10 | + | |
| 11 | +class Migration(migrations.Migration): | |
| 12 | + dependencies = [ | |
| 13 | + ("projects", "0001_initial"), | |
| 14 | + migrations.swappable_dependency(settings.AUTH_USER_MODEL), | |
| 15 | + ] | |
| 16 | + | |
| 17 | + operations = [ | |
| 18 | + migrations.CreateModel( | |
| 19 | + name="HistoricalProjectGroup", | |
| 20 | + fields=[ | |
| 21 | + ( | |
| 22 | + "id", | |
| 23 | + models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), | |
| 24 | + ), | |
| 25 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 26 | + ("created_at", models.DateTimeField(blank=True, editable=False)), | |
| 27 | + ("updated_at", models.DateTimeField(blank=True, editable=False)), | |
| 28 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 29 | + ( | |
| 30 | + "guid", | |
| 31 | + models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), | |
| 32 | + ), | |
| 33 | + ("name", models.CharField(max_length=200)), | |
| 34 | + ("slug", models.SlugField(max_length=200)), | |
| 35 | + ("description", models.TextField(blank=True, default="")), | |
| 36 | + ("history_id", models.AutoField(primary_key=True, serialize=False)), | |
| 37 | + ("history_date", models.DateTimeField(db_index=True)), | |
| 38 | + ("history_change_reason", models.CharField(max_length=100, null=True)), | |
| 39 | + ( | |
| 40 | + "history_type", | |
| 41 | + models.CharField( | |
| 42 | + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], | |
| 43 | + max_length=1, | |
| 44 | + ), | |
| 45 | + ), | |
| 46 | + ( | |
| 47 | + "created_by", | |
| 48 | + models.ForeignKey( | |
| 49 | + blank=True, | |
| 50 | + db_constraint=False, | |
| 51 | + null=True, | |
| 52 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 53 | + related_name="+", | |
| 54 | + to=settings.AUTH_USER_MODEL, | |
| 55 | + ), | |
| 56 | + ), | |
| 57 | + ( | |
| 58 | + "deleted_by", | |
| 59 | + models.ForeignKey( | |
| 60 | + blank=True, | |
| 61 | + db_constraint=False, | |
| 62 | + null=True, | |
| 63 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 64 | + related_name="+", | |
| 65 | + to=settings.AUTH_USER_MODEL, | |
| 66 | + ), | |
| 67 | + ), | |
| 68 | + ( | |
| 69 | + "history_user", | |
| 70 | + models.ForeignKey( | |
| 71 | + null=True, | |
| 72 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 73 | + related_name="+", | |
| 74 | + to=settings.AUTH_USER_MODEL, | |
| 75 | + ), | |
| 76 | + ), | |
| 77 | + ( | |
| 78 | + "updated_by", | |
| 79 | + models.ForeignKey( | |
| 80 | + blank=True, | |
| 81 | + db_constraint=False, | |
| 82 | + null=True, | |
| 83 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 84 | + related_name="+", | |
| 85 | + to=settings.AUTH_USER_MODEL, | |
| 86 | + ), | |
| 87 | + ), | |
| 88 | + ], | |
| 89 | + options={ | |
| 90 | + "verbose_name": "historical project group", | |
| 91 | + "verbose_name_plural": "historical project groups", | |
| 92 | + "ordering": ("-history_date", "-history_id"), | |
| 93 | + "get_latest_by": ("history_date", "history_id"), | |
| 94 | + }, | |
| 95 | + bases=(simple_history.models.HistoricalChanges, models.Model), | |
| 96 | + ), | |
| 97 | + migrations.CreateModel( | |
| 98 | + name="ProjectGroup", | |
| 99 | + fields=[ | |
| 100 | + ( | |
| 101 | + "id", | |
| 102 | + models.BigAutoField( | |
| 103 | + auto_created=True, | |
| 104 | + primary_key=True, | |
| 105 | + serialize=False, | |
| 106 | + verbose_name="ID", | |
| 107 | + ), | |
| 108 | + ), | |
| 109 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 110 | + ("created_at", models.DateTimeField(auto_now_add=True)), | |
| 111 | + ("updated_at", models.DateTimeField(auto_now=True)), | |
| 112 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 113 | + ( | |
| 114 | + "guid", | |
| 115 | + models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True), | |
| 116 | + ), | |
| 117 | + ("name", models.CharField(max_length=200)), | |
| 118 | + ("slug", models.SlugField(max_length=200, unique=True)), | |
| 119 | + ("description", models.TextField(blank=True, default="")), | |
| 120 | + ( | |
| 121 | + "created_by", | |
| 122 | + models.ForeignKey( | |
| 123 | + blank=True, | |
| 124 | + null=True, | |
| 125 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 126 | + related_name="+", | |
| 127 | + to=settings.AUTH_USER_MODEL, | |
| 128 | + ), | |
| 129 | + ), | |
| 130 | + ( | |
| 131 | + "deleted_by", | |
| 132 | + models.ForeignKey( | |
| 133 | + blank=True, | |
| 134 | + null=True, | |
| 135 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 136 | + related_name="+", | |
| 137 | + to=settings.AUTH_USER_MODEL, | |
| 138 | + ), | |
| 139 | + ), | |
| 140 | + ( | |
| 141 | + "updated_by", | |
| 142 | + models.ForeignKey( | |
| 143 | + blank=True, | |
| 144 | + null=True, | |
| 145 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 146 | + related_name="+", | |
| 147 | + to=settings.AUTH_USER_MODEL, | |
| 148 | + ), | |
| 149 | + ), | |
| 150 | + ], | |
| 151 | + options={ | |
| 152 | + "ordering": ["name"], | |
| 153 | + }, | |
| 154 | + ), | |
| 155 | + migrations.AddField( | |
| 156 | + model_name="historicalproject", | |
| 157 | + name="group", | |
| 158 | + field=models.ForeignKey( | |
| 159 | + blank=True, | |
| 160 | + db_constraint=False, | |
| 161 | + help_text="Optional group for organizing related projects", | |
| 162 | + null=True, | |
| 163 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 164 | + related_name="+", | |
| 165 | + to="projects.projectgroup", | |
| 166 | + ), | |
| 167 | + ), | |
| 168 | + migrations.AddField( | |
| 169 | + model_name="project", | |
| 170 | + name="group", | |
| 171 | + field=models.ForeignKey( | |
| 172 | + blank=True, | |
| 173 | + help_text="Optional group for organizing related projects", | |
| 174 | + null=True, | |
| 175 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 176 | + related_name="projects", | |
| 177 | + to="projects.projectgroup", | |
| 178 | + ), | |
| 179 | + ), | |
| 180 | + ] |
| --- a/projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py | |
| +++ b/projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py | |
| @@ -0,0 +1,180 @@ | |
| --- a/projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py | |
| +++ b/projects/migrations/0002_historicalprojectgroup_projectgroup_and_more.py | |
| @@ -0,0 +1,180 @@ | |
| 1 | # Generated by Django 5.2.12 on 2026-04-07 14:24 |
| 2 | |
| 3 | import uuid |
| 4 | |
| 5 | import django.db.models.deletion |
| 6 | import simple_history.models |
| 7 | from django.conf import settings |
| 8 | from django.db import migrations, models |
| 9 | |
| 10 | |
| 11 | class Migration(migrations.Migration): |
| 12 | dependencies = [ |
| 13 | ("projects", "0001_initial"), |
| 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
| 15 | ] |
| 16 | |
| 17 | operations = [ |
| 18 | migrations.CreateModel( |
| 19 | name="HistoricalProjectGroup", |
| 20 | fields=[ |
| 21 | ( |
| 22 | "id", |
| 23 | models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"), |
| 24 | ), |
| 25 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 26 | ("created_at", models.DateTimeField(blank=True, editable=False)), |
| 27 | ("updated_at", models.DateTimeField(blank=True, editable=False)), |
| 28 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 29 | ( |
| 30 | "guid", |
| 31 | models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), |
| 32 | ), |
| 33 | ("name", models.CharField(max_length=200)), |
| 34 | ("slug", models.SlugField(max_length=200)), |
| 35 | ("description", models.TextField(blank=True, default="")), |
| 36 | ("history_id", models.AutoField(primary_key=True, serialize=False)), |
| 37 | ("history_date", models.DateTimeField(db_index=True)), |
| 38 | ("history_change_reason", models.CharField(max_length=100, null=True)), |
| 39 | ( |
| 40 | "history_type", |
| 41 | models.CharField( |
| 42 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], |
| 43 | max_length=1, |
| 44 | ), |
| 45 | ), |
| 46 | ( |
| 47 | "created_by", |
| 48 | models.ForeignKey( |
| 49 | blank=True, |
| 50 | db_constraint=False, |
| 51 | null=True, |
| 52 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 53 | related_name="+", |
| 54 | to=settings.AUTH_USER_MODEL, |
| 55 | ), |
| 56 | ), |
| 57 | ( |
| 58 | "deleted_by", |
| 59 | models.ForeignKey( |
| 60 | blank=True, |
| 61 | db_constraint=False, |
| 62 | null=True, |
| 63 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 64 | related_name="+", |
| 65 | to=settings.AUTH_USER_MODEL, |
| 66 | ), |
| 67 | ), |
| 68 | ( |
| 69 | "history_user", |
| 70 | models.ForeignKey( |
| 71 | null=True, |
| 72 | on_delete=django.db.models.deletion.SET_NULL, |
| 73 | related_name="+", |
| 74 | to=settings.AUTH_USER_MODEL, |
| 75 | ), |
| 76 | ), |
| 77 | ( |
| 78 | "updated_by", |
| 79 | models.ForeignKey( |
| 80 | blank=True, |
| 81 | db_constraint=False, |
| 82 | null=True, |
| 83 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 84 | related_name="+", |
| 85 | to=settings.AUTH_USER_MODEL, |
| 86 | ), |
| 87 | ), |
| 88 | ], |
| 89 | options={ |
| 90 | "verbose_name": "historical project group", |
| 91 | "verbose_name_plural": "historical project groups", |
| 92 | "ordering": ("-history_date", "-history_id"), |
| 93 | "get_latest_by": ("history_date", "history_id"), |
| 94 | }, |
| 95 | bases=(simple_history.models.HistoricalChanges, models.Model), |
| 96 | ), |
| 97 | migrations.CreateModel( |
| 98 | name="ProjectGroup", |
| 99 | fields=[ |
| 100 | ( |
| 101 | "id", |
| 102 | models.BigAutoField( |
| 103 | auto_created=True, |
| 104 | primary_key=True, |
| 105 | serialize=False, |
| 106 | verbose_name="ID", |
| 107 | ), |
| 108 | ), |
| 109 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 110 | ("created_at", models.DateTimeField(auto_now_add=True)), |
| 111 | ("updated_at", models.DateTimeField(auto_now=True)), |
| 112 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 113 | ( |
| 114 | "guid", |
| 115 | models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True), |
| 116 | ), |
| 117 | ("name", models.CharField(max_length=200)), |
| 118 | ("slug", models.SlugField(max_length=200, unique=True)), |
| 119 | ("description", models.TextField(blank=True, default="")), |
| 120 | ( |
| 121 | "created_by", |
| 122 | models.ForeignKey( |
| 123 | blank=True, |
| 124 | null=True, |
| 125 | on_delete=django.db.models.deletion.SET_NULL, |
| 126 | related_name="+", |
| 127 | to=settings.AUTH_USER_MODEL, |
| 128 | ), |
| 129 | ), |
| 130 | ( |
| 131 | "deleted_by", |
| 132 | models.ForeignKey( |
| 133 | blank=True, |
| 134 | null=True, |
| 135 | on_delete=django.db.models.deletion.SET_NULL, |
| 136 | related_name="+", |
| 137 | to=settings.AUTH_USER_MODEL, |
| 138 | ), |
| 139 | ), |
| 140 | ( |
| 141 | "updated_by", |
| 142 | models.ForeignKey( |
| 143 | blank=True, |
| 144 | null=True, |
| 145 | on_delete=django.db.models.deletion.SET_NULL, |
| 146 | related_name="+", |
| 147 | to=settings.AUTH_USER_MODEL, |
| 148 | ), |
| 149 | ), |
| 150 | ], |
| 151 | options={ |
| 152 | "ordering": ["name"], |
| 153 | }, |
| 154 | ), |
| 155 | migrations.AddField( |
| 156 | model_name="historicalproject", |
| 157 | name="group", |
| 158 | field=models.ForeignKey( |
| 159 | blank=True, |
| 160 | db_constraint=False, |
| 161 | help_text="Optional group for organizing related projects", |
| 162 | null=True, |
| 163 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 164 | related_name="+", |
| 165 | to="projects.projectgroup", |
| 166 | ), |
| 167 | ), |
| 168 | migrations.AddField( |
| 169 | model_name="project", |
| 170 | name="group", |
| 171 | field=models.ForeignKey( |
| 172 | blank=True, |
| 173 | help_text="Optional group for organizing related projects", |
| 174 | null=True, |
| 175 | on_delete=django.db.models.deletion.SET_NULL, |
| 176 | related_name="projects", |
| 177 | to="projects.projectgroup", |
| 178 | ), |
| 179 | ), |
| 180 | ] |
| --- projects/models.py | ||
| +++ projects/models.py | ||
| @@ -1,17 +1,38 @@ | ||
| 1 | 1 | from django.db import models |
| 2 | 2 | |
| 3 | 3 | from core.models import ActiveManager, BaseCoreModel, Tracking |
| 4 | 4 | |
| 5 | + | |
| 6 | +class ProjectGroup(BaseCoreModel): | |
| 7 | + """Groups related projects together (e.g., Fossil SCM source + forum + docs).""" | |
| 8 | + | |
| 9 | + objects = ActiveManager() | |
| 10 | + all_objects = models.Manager() | |
| 11 | + | |
| 12 | + class Meta: | |
| 13 | + ordering = ["name"] | |
| 14 | + | |
| 15 | + def __str__(self): | |
| 16 | + return self.name | |
| 17 | + | |
| 5 | 18 | |
| 6 | 19 | class Project(BaseCoreModel): |
| 7 | 20 | class Visibility(models.TextChoices): |
| 8 | 21 | PUBLIC = "public", "Public" |
| 9 | 22 | INTERNAL = "internal", "Internal" |
| 10 | 23 | PRIVATE = "private", "Private" |
| 11 | 24 | |
| 12 | 25 | organization = models.ForeignKey("organization.Organization", on_delete=models.CASCADE, related_name="projects") |
| 26 | + group = models.ForeignKey( | |
| 27 | + "ProjectGroup", | |
| 28 | + null=True, | |
| 29 | + blank=True, | |
| 30 | + on_delete=models.SET_NULL, | |
| 31 | + related_name="projects", | |
| 32 | + help_text="Optional group for organizing related projects", | |
| 33 | + ) | |
| 13 | 34 | visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE) |
| 14 | 35 | teams = models.ManyToManyField("organization.Team", through="ProjectTeam", blank=True, related_name="projects") |
| 15 | 36 | |
| 16 | 37 | objects = ActiveManager() |
| 17 | 38 | all_objects = models.Manager() |
| 18 | 39 |
| --- projects/models.py | |
| +++ projects/models.py | |
| @@ -1,17 +1,38 @@ | |
| 1 | from django.db import models |
| 2 | |
| 3 | from core.models import ActiveManager, BaseCoreModel, Tracking |
| 4 | |
| 5 | |
| 6 | class Project(BaseCoreModel): |
| 7 | class Visibility(models.TextChoices): |
| 8 | PUBLIC = "public", "Public" |
| 9 | INTERNAL = "internal", "Internal" |
| 10 | PRIVATE = "private", "Private" |
| 11 | |
| 12 | organization = models.ForeignKey("organization.Organization", on_delete=models.CASCADE, related_name="projects") |
| 13 | visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE) |
| 14 | teams = models.ManyToManyField("organization.Team", through="ProjectTeam", blank=True, related_name="projects") |
| 15 | |
| 16 | objects = ActiveManager() |
| 17 | all_objects = models.Manager() |
| 18 |
| --- projects/models.py | |
| +++ projects/models.py | |
| @@ -1,17 +1,38 @@ | |
| 1 | from django.db import models |
| 2 | |
| 3 | from core.models import ActiveManager, BaseCoreModel, Tracking |
| 4 | |
| 5 | |
| 6 | class ProjectGroup(BaseCoreModel): |
| 7 | """Groups related projects together (e.g., Fossil SCM source + forum + docs).""" |
| 8 | |
| 9 | objects = ActiveManager() |
| 10 | all_objects = models.Manager() |
| 11 | |
| 12 | class Meta: |
| 13 | ordering = ["name"] |
| 14 | |
| 15 | def __str__(self): |
| 16 | return self.name |
| 17 | |
| 18 | |
| 19 | class Project(BaseCoreModel): |
| 20 | class Visibility(models.TextChoices): |
| 21 | PUBLIC = "public", "Public" |
| 22 | INTERNAL = "internal", "Internal" |
| 23 | PRIVATE = "private", "Private" |
| 24 | |
| 25 | organization = models.ForeignKey("organization.Organization", on_delete=models.CASCADE, related_name="projects") |
| 26 | group = models.ForeignKey( |
| 27 | "ProjectGroup", |
| 28 | null=True, |
| 29 | blank=True, |
| 30 | on_delete=models.SET_NULL, |
| 31 | related_name="projects", |
| 32 | help_text="Optional group for organizing related projects", |
| 33 | ) |
| 34 | visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE) |
| 35 | teams = models.ManyToManyField("organization.Team", through="ProjectTeam", blank=True, related_name="projects") |
| 36 | |
| 37 | objects = ActiveManager() |
| 38 | all_objects = models.Manager() |
| 39 |
| --- projects/urls.py | ||
| +++ projects/urls.py | ||
| @@ -5,10 +5,17 @@ | ||
| 5 | 5 | app_name = "projects" |
| 6 | 6 | |
| 7 | 7 | urlpatterns = [ |
| 8 | 8 | path("", views.project_list, name="list"), |
| 9 | 9 | path("create/", views.project_create, name="create"), |
| 10 | + # Groups (before <slug:slug>/ catch-all) | |
| 11 | + path("groups/", views.group_list, name="group_list"), | |
| 12 | + path("groups/create/", views.group_create, name="group_create"), | |
| 13 | + path("groups/<slug:slug>/", views.group_detail, name="group_detail"), | |
| 14 | + path("groups/<slug:slug>/edit/", views.group_edit, name="group_edit"), | |
| 15 | + path("groups/<slug:slug>/delete/", views.group_delete, name="group_delete"), | |
| 16 | + # Projects | |
| 10 | 17 | path("<slug:slug>/", views.project_detail, name="detail"), |
| 11 | 18 | path("<slug:slug>/edit/", views.project_update, name="update"), |
| 12 | 19 | path("<slug:slug>/delete/", views.project_delete, name="delete"), |
| 13 | 20 | path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"), |
| 14 | 21 | path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"), |
| 15 | 22 |
| --- projects/urls.py | |
| +++ projects/urls.py | |
| @@ -5,10 +5,17 @@ | |
| 5 | app_name = "projects" |
| 6 | |
| 7 | urlpatterns = [ |
| 8 | path("", views.project_list, name="list"), |
| 9 | path("create/", views.project_create, name="create"), |
| 10 | path("<slug:slug>/", views.project_detail, name="detail"), |
| 11 | path("<slug:slug>/edit/", views.project_update, name="update"), |
| 12 | path("<slug:slug>/delete/", views.project_delete, name="delete"), |
| 13 | path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"), |
| 14 | path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"), |
| 15 |
| --- projects/urls.py | |
| +++ projects/urls.py | |
| @@ -5,10 +5,17 @@ | |
| 5 | app_name = "projects" |
| 6 | |
| 7 | urlpatterns = [ |
| 8 | path("", views.project_list, name="list"), |
| 9 | path("create/", views.project_create, name="create"), |
| 10 | # Groups (before <slug:slug>/ catch-all) |
| 11 | path("groups/", views.group_list, name="group_list"), |
| 12 | path("groups/create/", views.group_create, name="group_create"), |
| 13 | path("groups/<slug:slug>/", views.group_detail, name="group_detail"), |
| 14 | path("groups/<slug:slug>/edit/", views.group_edit, name="group_edit"), |
| 15 | path("groups/<slug:slug>/delete/", views.group_delete, name="group_delete"), |
| 16 | # Projects |
| 17 | path("<slug:slug>/", views.project_detail, name="detail"), |
| 18 | path("<slug:slug>/edit/", views.project_update, name="update"), |
| 19 | path("<slug:slug>/delete/", views.project_delete, name="delete"), |
| 20 | path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"), |
| 21 | path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"), |
| 22 |
| --- projects/views.py | ||
| +++ projects/views.py | ||
| @@ -5,12 +5,12 @@ | ||
| 5 | 5 | |
| 6 | 6 | from core.permissions import P |
| 7 | 7 | from organization.models import Team |
| 8 | 8 | from organization.views import get_org |
| 9 | 9 | |
| 10 | -from .forms import ProjectForm, ProjectTeamAddForm, ProjectTeamEditForm | |
| 11 | -from .models import Project, ProjectTeam | |
| 10 | +from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm | |
| 11 | +from .models import Project, ProjectGroup, ProjectTeam | |
| 12 | 12 | |
| 13 | 13 | |
| 14 | 14 | @login_required |
| 15 | 15 | def project_list(request): |
| 16 | 16 | P.PROJECT_VIEW.check(request.user) |
| @@ -236,5 +236,84 @@ | ||
| 236 | 236 | return HttpResponse(status=200, headers={"HX-Redirect": f"/projects/{project.slug}/"}) |
| 237 | 237 | |
| 238 | 238 | return redirect("projects:detail", slug=project.slug) |
| 239 | 239 | |
| 240 | 240 | return render(request, "projects/project_team_confirm_remove.html", {"project": project, "team": team}) |
| 241 | + | |
| 242 | + | |
| 243 | +# --- Project Groups --- | |
| 244 | + | |
| 245 | + | |
| 246 | +@login_required | |
| 247 | +def group_list(request): | |
| 248 | + P.PROJECT_GROUP_VIEW.check(request.user) | |
| 249 | + groups = ProjectGroup.objects.all().prefetch_related("projects") | |
| 250 | + | |
| 251 | + if request.headers.get("HX-Request"): | |
| 252 | + return render(request, "projects/partials/group_table.html", {"groups": groups}) | |
| 253 | + | |
| 254 | + return render(request, "projects/group_list.html", {"groups": groups}) | |
| 255 | + | |
| 256 | + | |
| 257 | +@login_required | |
| 258 | +def group_create(request): | |
| 259 | + P.PROJECT_GROUP_ADD.check(request.user) | |
| 260 | + | |
| 261 | + if request.method == "POST": | |
| 262 | + form = ProjectGroupForm(request.POST) | |
| 263 | + if form.is_valid(): | |
| 264 | + group = form.save(commit=False) | |
| 265 | + group.created_by = request.user | |
| 266 | + group.save() | |
| 267 | + messages.success(request, f'Group "{group.name}" created.') | |
| 268 | + return redirect("projects:group_detail", slug=group.slug) | |
| 269 | + else: | |
| 270 | + form = ProjectGroupForm() | |
| 271 | + | |
| 272 | + return render(request, "projects/group_form.html", {"form": form, "title": "New Group"}) | |
| 273 | + | |
| 274 | + | |
| 275 | +@login_required | |
| 276 | +def group_detail(request, slug): | |
| 277 | + P.PROJECT_GROUP_VIEW.check(request.user) | |
| 278 | + group = get_object_or_404(ProjectGroup, slug=slug, deleted_at__isnull=True) | |
| 279 | + group_projects = Project.objects.filter(group=group) | |
| 280 | + | |
| 281 | + return render(request, "projects/group_detail.html", {"group": group, "group_projects": group_projects}) | |
| 282 | + | |
| 283 | + | |
| 284 | +@login_required | |
| 285 | +def group_edit(request, slug): | |
| 286 | + P.PROJECT_GROUP_CHANGE.check(request.user) | |
| 287 | + group = get_object_or_404(ProjectGroup, slug=slug, deleted_at__isnull=True) | |
| 288 | + | |
| 289 | + if request.method == "POST": | |
| 290 | + form = ProjectGroupForm(request.POST, instance=group) | |
| 291 | + if form.is_valid(): | |
| 292 | + group = form.save(commit=False) | |
| 293 | + group.updated_by = request.user | |
| 294 | + group.save() | |
| 295 | + messages.success(request, f'Group "{group.name}" updated.') | |
| 296 | + return redirect("projects:group_detail", slug=group.slug) | |
| 297 | + else: | |
| 298 | + form = ProjectGroupForm(instance=group) | |
| 299 | + | |
| 300 | + return render(request, "projects/group_form.html", {"form": form, "group": group, "title": "Edit Group"}) | |
| 301 | + | |
| 302 | + | |
| 303 | +@login_required | |
| 304 | +def group_delete(request, slug): | |
| 305 | + P.PROJECT_GROUP_DELETE.check(request.user) | |
| 306 | + group = get_object_or_404(ProjectGroup, slug=slug, deleted_at__isnull=True) | |
| 307 | + | |
| 308 | + if request.method == "POST": | |
| 309 | + # Unlink projects from this group before soft-deleting | |
| 310 | + Project.objects.filter(group=group).update(group=None) | |
| 311 | + group.soft_delete(user=request.user) | |
| 312 | + messages.success(request, f'Group "{group.name}" deleted.') | |
| 313 | + | |
| 314 | + if request.headers.get("HX-Request"): | |
| 315 | + return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"}) | |
| 316 | + | |
| 317 | + return redirect("projects:group_list") | |
| 318 | + | |
| 319 | + return render(request, "projects/group_confirm_delete.html", {"group": group}) | |
| 241 | 320 | |
| 242 | 321 | ADDED templates/includes/_sidebar_project.html |
| --- projects/views.py | |
| +++ projects/views.py | |
| @@ -5,12 +5,12 @@ | |
| 5 | |
| 6 | from core.permissions import P |
| 7 | from organization.models import Team |
| 8 | from organization.views import get_org |
| 9 | |
| 10 | from .forms import ProjectForm, ProjectTeamAddForm, ProjectTeamEditForm |
| 11 | from .models import Project, ProjectTeam |
| 12 | |
| 13 | |
| 14 | @login_required |
| 15 | def project_list(request): |
| 16 | P.PROJECT_VIEW.check(request.user) |
| @@ -236,5 +236,84 @@ | |
| 236 | return HttpResponse(status=200, headers={"HX-Redirect": f"/projects/{project.slug}/"}) |
| 237 | |
| 238 | return redirect("projects:detail", slug=project.slug) |
| 239 | |
| 240 | return render(request, "projects/project_team_confirm_remove.html", {"project": project, "team": team}) |
| 241 | |
| 242 | DDED templates/includes/_sidebar_project.html |
| --- projects/views.py | |
| +++ projects/views.py | |
| @@ -5,12 +5,12 @@ | |
| 5 | |
| 6 | from core.permissions import P |
| 7 | from organization.models import Team |
| 8 | from organization.views import get_org |
| 9 | |
| 10 | from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm |
| 11 | from .models import Project, ProjectGroup, ProjectTeam |
| 12 | |
| 13 | |
| 14 | @login_required |
| 15 | def project_list(request): |
| 16 | P.PROJECT_VIEW.check(request.user) |
| @@ -236,5 +236,84 @@ | |
| 236 | return HttpResponse(status=200, headers={"HX-Redirect": f"/projects/{project.slug}/"}) |
| 237 | |
| 238 | return redirect("projects:detail", slug=project.slug) |
| 239 | |
| 240 | return render(request, "projects/project_team_confirm_remove.html", {"project": project, "team": team}) |
| 241 | |
| 242 | |
| 243 | # --- Project Groups --- |
| 244 | |
| 245 | |
| 246 | @login_required |
| 247 | def group_list(request): |
| 248 | P.PROJECT_GROUP_VIEW.check(request.user) |
| 249 | groups = ProjectGroup.objects.all().prefetch_related("projects") |
| 250 | |
| 251 | if request.headers.get("HX-Request"): |
| 252 | return render(request, "projects/partials/group_table.html", {"groups": groups}) |
| 253 | |
| 254 | return render(request, "projects/group_list.html", {"groups": groups}) |
| 255 | |
| 256 | |
| 257 | @login_required |
| 258 | def group_create(request): |
| 259 | P.PROJECT_GROUP_ADD.check(request.user) |
| 260 | |
| 261 | if request.method == "POST": |
| 262 | form = ProjectGroupForm(request.POST) |
| 263 | if form.is_valid(): |
| 264 | group = form.save(commit=False) |
| 265 | group.created_by = request.user |
| 266 | group.save() |
| 267 | messages.success(request, f'Group "{group.name}" created.') |
| 268 | return redirect("projects:group_detail", slug=group.slug) |
| 269 | else: |
| 270 | form = ProjectGroupForm() |
| 271 | |
| 272 | return render(request, "projects/group_form.html", {"form": form, "title": "New Group"}) |
| 273 | |
| 274 | |
| 275 | @login_required |
| 276 | def group_detail(request, slug): |
| 277 | P.PROJECT_GROUP_VIEW.check(request.user) |
| 278 | group = get_object_or_404(ProjectGroup, slug=slug, deleted_at__isnull=True) |
| 279 | group_projects = Project.objects.filter(group=group) |
| 280 | |
| 281 | return render(request, "projects/group_detail.html", {"group": group, "group_projects": group_projects}) |
| 282 | |
| 283 | |
| 284 | @login_required |
| 285 | def group_edit(request, slug): |
| 286 | P.PROJECT_GROUP_CHANGE.check(request.user) |
| 287 | group = get_object_or_404(ProjectGroup, slug=slug, deleted_at__isnull=True) |
| 288 | |
| 289 | if request.method == "POST": |
| 290 | form = ProjectGroupForm(request.POST, instance=group) |
| 291 | if form.is_valid(): |
| 292 | group = form.save(commit=False) |
| 293 | group.updated_by = request.user |
| 294 | group.save() |
| 295 | messages.success(request, f'Group "{group.name}" updated.') |
| 296 | return redirect("projects:group_detail", slug=group.slug) |
| 297 | else: |
| 298 | form = ProjectGroupForm(instance=group) |
| 299 | |
| 300 | return render(request, "projects/group_form.html", {"form": form, "group": group, "title": "Edit Group"}) |
| 301 | |
| 302 | |
| 303 | @login_required |
| 304 | def group_delete(request, slug): |
| 305 | P.PROJECT_GROUP_DELETE.check(request.user) |
| 306 | group = get_object_or_404(ProjectGroup, slug=slug, deleted_at__isnull=True) |
| 307 | |
| 308 | if request.method == "POST": |
| 309 | # Unlink projects from this group before soft-deleting |
| 310 | Project.objects.filter(group=group).update(group=None) |
| 311 | group.soft_delete(user=request.user) |
| 312 | messages.success(request, f'Group "{group.name}" deleted.') |
| 313 | |
| 314 | if request.headers.get("HX-Request"): |
| 315 | return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"}) |
| 316 | |
| 317 | return redirect("projects:group_list") |
| 318 | |
| 319 | return render(request, "projects/group_confirm_delete.html", {"group": group}) |
| 320 | |
| 321 | DDED templates/includes/_sidebar_project.html |
| --- a/templates/includes/_sidebar_project.html | ||
| +++ b/templates/includes/_sidebar_project.html | ||
| @@ -0,0 +1,41 @@ | ||
| 1 | +{% comment %} | |
| 2 | +Reusable sidebar project tree item. Expects `project` in context. | |
| 3 | +Shows the project name with expandable sub-links (Overview, Code, Timeline, etc.). | |
| 4 | +{% endcomment %} | |
| 5 | +<div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }"> | |
| 6 | + <button @click="open = !open" | |
| 7 | + class="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm {% if project.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 8 | + <span class="truncate">{{ project.name }}</span> | |
| 9 | + <svg class="h-3 w-3 flex-shrink-0 transition-transform" :class="open && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> | |
| 10 | + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> | |
| 11 | + </svg> | |
| 12 | + </button> | |
| 13 | + <div x-show="open" x-collapse class="ml-3 mt-0.5 space-y-0.5 border-l border-gray-700/50 pl-2"> | |
| 14 | + <a href="{% url 'projects:detail' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> | |
| 15 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg> | |
| 16 | + Overview | |
| 17 | + </a> | |
| 18 | + {% with pslug=project.slug %} | |
| 19 | + <a href="{% url 'fossil:code' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/code' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 20 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg> | |
| 21 | + Code | |
| 22 | + </a> | |
| 23 | + <a href="{% url 'fossil:timeline' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/timeline' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 24 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 25 | + Timeline | |
| 26 | + </a> | |
| 27 | + <a href="{% url 'fossil:tickets' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/tickets' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 28 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg> | |
| 29 | + Tickets | |
| 30 | + </a> | |
| 31 | + <a href="{% url 'fossil:wiki' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/wiki' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 32 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg> | |
| 33 | + Wiki | |
| 34 | + </a> | |
| 35 | + <a href="{% url 'fossil:forum' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/forum' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 36 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg> | |
| 37 | + Forum | |
| 38 | + </a> | |
| 39 | + {% endwith %} | |
| 40 | + </div> | |
| 41 | +</div> |
| --- a/templates/includes/_sidebar_project.html | |
| +++ b/templates/includes/_sidebar_project.html | |
| @@ -0,0 +1,41 @@ | |
| --- a/templates/includes/_sidebar_project.html | |
| +++ b/templates/includes/_sidebar_project.html | |
| @@ -0,0 +1,41 @@ | |
| 1 | {% comment %} |
| 2 | Reusable sidebar project tree item. Expects `project` in context. |
| 3 | Shows the project name with expandable sub-links (Overview, Code, Timeline, etc.). |
| 4 | {% endcomment %} |
| 5 | <div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }"> |
| 6 | <button @click="open = !open" |
| 7 | class="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm {% if project.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 8 | <span class="truncate">{{ project.name }}</span> |
| 9 | <svg class="h-3 w-3 flex-shrink-0 transition-transform" :class="open && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> |
| 10 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 11 | </svg> |
| 12 | </button> |
| 13 | <div x-show="open" x-collapse class="ml-3 mt-0.5 space-y-0.5 border-l border-gray-700/50 pl-2"> |
| 14 | <a href="{% url 'projects:detail' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> |
| 15 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg> |
| 16 | Overview |
| 17 | </a> |
| 18 | {% with pslug=project.slug %} |
| 19 | <a href="{% url 'fossil:code' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/code' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 20 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg> |
| 21 | Code |
| 22 | </a> |
| 23 | <a href="{% url 'fossil:timeline' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/timeline' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 24 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> |
| 25 | Timeline |
| 26 | </a> |
| 27 | <a href="{% url 'fossil:tickets' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/tickets' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 28 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg> |
| 29 | Tickets |
| 30 | </a> |
| 31 | <a href="{% url 'fossil:wiki' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/wiki' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 32 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg> |
| 33 | Wiki |
| 34 | </a> |
| 35 | <a href="{% url 'fossil:forum' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/forum' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 36 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg> |
| 37 | Forum |
| 38 | </a> |
| 39 | {% endwith %} |
| 40 | </div> |
| 41 | </div> |
| --- templates/includes/sidebar.html | ||
| +++ templates/includes/sidebar.html | ||
| @@ -40,48 +40,25 @@ | ||
| 40 | 40 | <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 41 | 41 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 42 | 42 | </svg> |
| 43 | 43 | </button> |
| 44 | 44 | <div x-show="projectsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-2"> |
| 45 | - {% for project in sidebar_projects %} | |
| 46 | - <div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }"> | |
| 47 | - <button @click="open = !open" | |
| 48 | - class="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm {% if project.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 49 | - <span class="truncate">{{ project.name }}</span> | |
| 50 | - <svg class="h-3 w-3 flex-shrink-0 transition-transform" :class="open && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> | |
| 51 | - <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> | |
| 52 | - </svg> | |
| 53 | - </button> | |
| 54 | - <div x-show="open" x-collapse class="ml-3 mt-0.5 space-y-0.5 border-l border-gray-700/50 pl-2"> | |
| 55 | - <a href="{% url 'projects:detail' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> | |
| 56 | - <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg> | |
| 57 | - Overview | |
| 58 | - </a> | |
| 59 | - {% with pslug=project.slug %} | |
| 60 | - <a href="{% url 'fossil:code' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/code' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 61 | - <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg> | |
| 62 | - Code | |
| 63 | - </a> | |
| 64 | - <a href="{% url 'fossil:timeline' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/timeline' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 65 | - <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 66 | - Timeline | |
| 67 | - </a> | |
| 68 | - <a href="{% url 'fossil:tickets' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/tickets' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 69 | - <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg> | |
| 70 | - Tickets | |
| 71 | - </a> | |
| 72 | - <a href="{% url 'fossil:wiki' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/wiki' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 73 | - <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg> | |
| 74 | - Wiki | |
| 75 | - </a> | |
| 76 | - <a href="{% url 'fossil:forum' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/forum' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 77 | - <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg> | |
| 78 | - Forum | |
| 79 | - </a> | |
| 80 | - {% endwith %} | |
| 81 | - </div> | |
| 82 | - </div> | |
| 45 | + {% for entry in sidebar_grouped %} | |
| 46 | + <div class="mt-2"> | |
| 47 | + <div class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold text-gray-500 uppercase tracking-wider"> | |
| 48 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 49 | + <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" /> | |
| 50 | + </svg> | |
| 51 | + {{ entry.group.name }} | |
| 52 | + </div> | |
| 53 | + {% for project in entry.projects %} | |
| 54 | + {% include "includes/_sidebar_project.html" %} | |
| 55 | + {% endfor %} | |
| 56 | + </div> | |
| 57 | + {% endfor %} | |
| 58 | + {% for project in sidebar_ungrouped %} | |
| 59 | + {% include "includes/_sidebar_project.html" %} | |
| 83 | 60 | {% endfor %} |
| 84 | 61 | {% if perms.projects.add_project %} |
| 85 | 62 | <a href="{% url 'projects:create' %}" |
| 86 | 63 | class="block rounded-md px-2 py-1.5 text-sm text-gray-600 hover:text-brand-light"> |
| 87 | 64 | + New |
| @@ -88,10 +65,22 @@ | ||
| 88 | 65 | </a> |
| 89 | 66 | {% endif %} |
| 90 | 67 | </div> |
| 91 | 68 | </div> |
| 92 | 69 | {% endif %} |
| 70 | + | |
| 71 | + <!-- Groups --> | |
| 72 | + {% if perms.projects.view_projectgroup %} | |
| 73 | + <a href="{% url 'projects:group_list' %}" | |
| 74 | + class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/projects/groups/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 75 | + :title="collapsed ? 'Groups' : ''"> | |
| 76 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 77 | + <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" /> | |
| 78 | + </svg> | |
| 79 | + <span x-show="!collapsed" class="truncate">Groups</span> | |
| 80 | + </a> | |
| 81 | + {% endif %} | |
| 93 | 82 | |
| 94 | 83 | <!-- Teams --> |
| 95 | 84 | {% if perms.organization.view_team %} |
| 96 | 85 | <a href="{% url 'organization:team_list' %}" |
| 97 | 86 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/teams/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| @@ -100,10 +89,22 @@ | ||
| 100 | 89 | <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /> |
| 101 | 90 | </svg> |
| 102 | 91 | <span x-show="!collapsed" class="truncate">Teams</span> |
| 103 | 92 | </a> |
| 104 | 93 | {% endif %} |
| 94 | + | |
| 95 | + <!-- Roles --> | |
| 96 | + {% if perms.organization.view_organization %} | |
| 97 | + <a href="{% url 'organization:role_list' %}" | |
| 98 | + class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/roles/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 99 | + :title="collapsed ? 'Roles' : ''"> | |
| 100 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 101 | + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> | |
| 102 | + </svg> | |
| 103 | + <span x-show="!collapsed" class="truncate">Roles</span> | |
| 104 | + </a> | |
| 105 | + {% endif %} | |
| 105 | 106 | |
| 106 | 107 | <!-- Knowledge Base section --> |
| 107 | 108 | {% if perms.pages.view_page %} |
| 108 | 109 | <div> |
| 109 | 110 | <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" |
| @@ -147,11 +148,11 @@ | ||
| 147 | 148 | </a> |
| 148 | 149 | |
| 149 | 150 | <!-- Settings --> |
| 150 | 151 | {% if perms.organization.view_organization %} |
| 151 | 152 | <a href="{% url 'organization:settings' %}" |
| 152 | - class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/' in request.path and '/settings/teams/' not in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 153 | + class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/' in request.path and '/settings/teams/' not in request.path and '/settings/roles/' not in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 153 | 154 | :title="collapsed ? 'Settings' : ''"> |
| 154 | 155 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 155 | 156 | <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /> |
| 156 | 157 | <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> |
| 157 | 158 | </svg> |
| 158 | 159 |
| --- templates/includes/sidebar.html | |
| +++ templates/includes/sidebar.html | |
| @@ -40,48 +40,25 @@ | |
| 40 | <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 41 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 42 | </svg> |
| 43 | </button> |
| 44 | <div x-show="projectsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-2"> |
| 45 | {% for project in sidebar_projects %} |
| 46 | <div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }"> |
| 47 | <button @click="open = !open" |
| 48 | class="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm {% if project.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 49 | <span class="truncate">{{ project.name }}</span> |
| 50 | <svg class="h-3 w-3 flex-shrink-0 transition-transform" :class="open && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> |
| 51 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 52 | </svg> |
| 53 | </button> |
| 54 | <div x-show="open" x-collapse class="ml-3 mt-0.5 space-y-0.5 border-l border-gray-700/50 pl-2"> |
| 55 | <a href="{% url 'projects:detail' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> |
| 56 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg> |
| 57 | Overview |
| 58 | </a> |
| 59 | {% with pslug=project.slug %} |
| 60 | <a href="{% url 'fossil:code' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/code' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 61 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg> |
| 62 | Code |
| 63 | </a> |
| 64 | <a href="{% url 'fossil:timeline' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/timeline' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 65 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> |
| 66 | Timeline |
| 67 | </a> |
| 68 | <a href="{% url 'fossil:tickets' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/tickets' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 69 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg> |
| 70 | Tickets |
| 71 | </a> |
| 72 | <a href="{% url 'fossil:wiki' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/wiki' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 73 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg> |
| 74 | Wiki |
| 75 | </a> |
| 76 | <a href="{% url 'fossil:forum' slug=pslug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs {% if '/fossil/forum' in request.path and pslug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 77 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg> |
| 78 | Forum |
| 79 | </a> |
| 80 | {% endwith %} |
| 81 | </div> |
| 82 | </div> |
| 83 | {% endfor %} |
| 84 | {% if perms.projects.add_project %} |
| 85 | <a href="{% url 'projects:create' %}" |
| 86 | class="block rounded-md px-2 py-1.5 text-sm text-gray-600 hover:text-brand-light"> |
| 87 | + New |
| @@ -88,10 +65,22 @@ | |
| 88 | </a> |
| 89 | {% endif %} |
| 90 | </div> |
| 91 | </div> |
| 92 | {% endif %} |
| 93 | |
| 94 | <!-- Teams --> |
| 95 | {% if perms.organization.view_team %} |
| 96 | <a href="{% url 'organization:team_list' %}" |
| 97 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/teams/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| @@ -100,10 +89,22 @@ | |
| 100 | <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /> |
| 101 | </svg> |
| 102 | <span x-show="!collapsed" class="truncate">Teams</span> |
| 103 | </a> |
| 104 | {% endif %} |
| 105 | |
| 106 | <!-- Knowledge Base section --> |
| 107 | {% if perms.pages.view_page %} |
| 108 | <div> |
| 109 | <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" |
| @@ -147,11 +148,11 @@ | |
| 147 | </a> |
| 148 | |
| 149 | <!-- Settings --> |
| 150 | {% if perms.organization.view_organization %} |
| 151 | <a href="{% url 'organization:settings' %}" |
| 152 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/' in request.path and '/settings/teams/' not in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 153 | :title="collapsed ? 'Settings' : ''"> |
| 154 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 155 | <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /> |
| 156 | <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> |
| 157 | </svg> |
| 158 |
| --- templates/includes/sidebar.html | |
| +++ templates/includes/sidebar.html | |
| @@ -40,48 +40,25 @@ | |
| 40 | <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 41 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 42 | </svg> |
| 43 | </button> |
| 44 | <div x-show="projectsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-2"> |
| 45 | {% for entry in sidebar_grouped %} |
| 46 | <div class="mt-2"> |
| 47 | <div class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold text-gray-500 uppercase tracking-wider"> |
| 48 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 49 | <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" /> |
| 50 | </svg> |
| 51 | {{ entry.group.name }} |
| 52 | </div> |
| 53 | {% for project in entry.projects %} |
| 54 | {% include "includes/_sidebar_project.html" %} |
| 55 | {% endfor %} |
| 56 | </div> |
| 57 | {% endfor %} |
| 58 | {% for project in sidebar_ungrouped %} |
| 59 | {% include "includes/_sidebar_project.html" %} |
| 60 | {% endfor %} |
| 61 | {% if perms.projects.add_project %} |
| 62 | <a href="{% url 'projects:create' %}" |
| 63 | class="block rounded-md px-2 py-1.5 text-sm text-gray-600 hover:text-brand-light"> |
| 64 | + New |
| @@ -88,10 +65,22 @@ | |
| 65 | </a> |
| 66 | {% endif %} |
| 67 | </div> |
| 68 | </div> |
| 69 | {% endif %} |
| 70 | |
| 71 | <!-- Groups --> |
| 72 | {% if perms.projects.view_projectgroup %} |
| 73 | <a href="{% url 'projects:group_list' %}" |
| 74 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/projects/groups/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 75 | :title="collapsed ? 'Groups' : ''"> |
| 76 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 77 | <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776" /> |
| 78 | </svg> |
| 79 | <span x-show="!collapsed" class="truncate">Groups</span> |
| 80 | </a> |
| 81 | {% endif %} |
| 82 | |
| 83 | <!-- Teams --> |
| 84 | {% if perms.organization.view_team %} |
| 85 | <a href="{% url 'organization:team_list' %}" |
| 86 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/teams/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| @@ -100,10 +89,22 @@ | |
| 89 | <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /> |
| 90 | </svg> |
| 91 | <span x-show="!collapsed" class="truncate">Teams</span> |
| 92 | </a> |
| 93 | {% endif %} |
| 94 | |
| 95 | <!-- Roles --> |
| 96 | {% if perms.organization.view_organization %} |
| 97 | <a href="{% url 'organization:role_list' %}" |
| 98 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/roles/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 99 | :title="collapsed ? 'Roles' : ''"> |
| 100 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 101 | <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> |
| 102 | </svg> |
| 103 | <span x-show="!collapsed" class="truncate">Roles</span> |
| 104 | </a> |
| 105 | {% endif %} |
| 106 | |
| 107 | <!-- Knowledge Base section --> |
| 108 | {% if perms.pages.view_page %} |
| 109 | <div> |
| 110 | <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" |
| @@ -147,11 +148,11 @@ | |
| 148 | </a> |
| 149 | |
| 150 | <!-- Settings --> |
| 151 | {% if perms.organization.view_organization %} |
| 152 | <a href="{% url 'organization:settings' %}" |
| 153 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/' in request.path and '/settings/teams/' not in request.path and '/settings/roles/' not in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 154 | :title="collapsed ? 'Settings' : ''"> |
| 155 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 156 | <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /> |
| 157 | <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> |
| 158 | </svg> |
| 159 |
| --- templates/organization/partials/member_table.html | ||
| +++ templates/organization/partials/member_table.html | ||
| @@ -3,10 +3,11 @@ | ||
| 3 | 3 | <table class="min-w-full divide-y divide-gray-700"> |
| 4 | 4 | <thead class="bg-gray-900"> |
| 5 | 5 | <tr> |
| 6 | 6 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Username</th> |
| 7 | 7 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Email</th> |
| 8 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Role</th> | |
| 8 | 9 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Status</th> |
| 9 | 10 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Joined</th> |
| 10 | 11 | <th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-400">Actions</th> |
| 11 | 12 | </tr> |
| 12 | 13 | </thead> |
| @@ -15,10 +16,17 @@ | ||
| 15 | 16 | <tr class="hover:bg-gray-700/50"> |
| 16 | 17 | <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> |
| 17 | 18 | <a href="{% url 'organization:user_detail' username=membership.member.username %}" class="text-brand-light hover:text-brand">{{ membership.member.username }}</a> |
| 18 | 19 | </td> |
| 19 | 20 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ membership.member.email|default:"—" }}</td> |
| 21 | + <td class="px-6 py-4 whitespace-nowrap text-sm"> | |
| 22 | + {% if membership.role %} | |
| 23 | + <a href="{% url 'organization:role_detail' slug=membership.role.slug %}" class="inline-flex rounded-full bg-purple-900/50 px-2 text-xs font-semibold leading-5 text-purple-300 hover:text-purple-200">{{ membership.role.name }}</a> | |
| 24 | + {% else %} | |
| 25 | + <span class="text-gray-500">--</span> | |
| 26 | + {% endif %} | |
| 27 | + </td> | |
| 20 | 28 | <td class="px-6 py-4 whitespace-nowrap"> |
| 21 | 29 | {% if membership.is_active %} |
| 22 | 30 | <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span> |
| 23 | 31 | {% else %} |
| 24 | 32 | <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span> |
| @@ -31,12 +39,12 @@ | ||
| 31 | 39 | {% endif %} |
| 32 | 40 | </td> |
| 33 | 41 | </tr> |
| 34 | 42 | {% empty %} |
| 35 | 43 | <tr> |
| 36 | - <td colspan="5" class="px-6 py-8 text-center text-sm text-gray-400">No members found.</td> | |
| 44 | + <td colspan="6" class="px-6 py-8 text-center text-sm text-gray-400">No members found.</td> | |
| 37 | 45 | </tr> |
| 38 | 46 | {% endfor %} |
| 39 | 47 | </tbody> |
| 40 | 48 | </table> |
| 41 | 49 | </div> |
| 42 | 50 | </div> |
| 43 | 51 | |
| 44 | 52 | ADDED templates/organization/role_detail.html |
| 45 | 53 | ADDED templates/organization/role_list.html |
| --- templates/organization/partials/member_table.html | |
| +++ templates/organization/partials/member_table.html | |
| @@ -3,10 +3,11 @@ | |
| 3 | <table class="min-w-full divide-y divide-gray-700"> |
| 4 | <thead class="bg-gray-900"> |
| 5 | <tr> |
| 6 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Username</th> |
| 7 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Email</th> |
| 8 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Status</th> |
| 9 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Joined</th> |
| 10 | <th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-400">Actions</th> |
| 11 | </tr> |
| 12 | </thead> |
| @@ -15,10 +16,17 @@ | |
| 15 | <tr class="hover:bg-gray-700/50"> |
| 16 | <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> |
| 17 | <a href="{% url 'organization:user_detail' username=membership.member.username %}" class="text-brand-light hover:text-brand">{{ membership.member.username }}</a> |
| 18 | </td> |
| 19 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ membership.member.email|default:"—" }}</td> |
| 20 | <td class="px-6 py-4 whitespace-nowrap"> |
| 21 | {% if membership.is_active %} |
| 22 | <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span> |
| 23 | {% else %} |
| 24 | <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span> |
| @@ -31,12 +39,12 @@ | |
| 31 | {% endif %} |
| 32 | </td> |
| 33 | </tr> |
| 34 | {% empty %} |
| 35 | <tr> |
| 36 | <td colspan="5" class="px-6 py-8 text-center text-sm text-gray-400">No members found.</td> |
| 37 | </tr> |
| 38 | {% endfor %} |
| 39 | </tbody> |
| 40 | </table> |
| 41 | </div> |
| 42 | </div> |
| 43 | |
| 44 | DDED templates/organization/role_detail.html |
| 45 | DDED templates/organization/role_list.html |
| --- templates/organization/partials/member_table.html | |
| +++ templates/organization/partials/member_table.html | |
| @@ -3,10 +3,11 @@ | |
| 3 | <table class="min-w-full divide-y divide-gray-700"> |
| 4 | <thead class="bg-gray-900"> |
| 5 | <tr> |
| 6 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Username</th> |
| 7 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Email</th> |
| 8 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Role</th> |
| 9 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Status</th> |
| 10 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Joined</th> |
| 11 | <th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-400">Actions</th> |
| 12 | </tr> |
| 13 | </thead> |
| @@ -15,10 +16,17 @@ | |
| 16 | <tr class="hover:bg-gray-700/50"> |
| 17 | <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> |
| 18 | <a href="{% url 'organization:user_detail' username=membership.member.username %}" class="text-brand-light hover:text-brand">{{ membership.member.username }}</a> |
| 19 | </td> |
| 20 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ membership.member.email|default:"—" }}</td> |
| 21 | <td class="px-6 py-4 whitespace-nowrap text-sm"> |
| 22 | {% if membership.role %} |
| 23 | <a href="{% url 'organization:role_detail' slug=membership.role.slug %}" class="inline-flex rounded-full bg-purple-900/50 px-2 text-xs font-semibold leading-5 text-purple-300 hover:text-purple-200">{{ membership.role.name }}</a> |
| 24 | {% else %} |
| 25 | <span class="text-gray-500">--</span> |
| 26 | {% endif %} |
| 27 | </td> |
| 28 | <td class="px-6 py-4 whitespace-nowrap"> |
| 29 | {% if membership.is_active %} |
| 30 | <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span> |
| 31 | {% else %} |
| 32 | <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span> |
| @@ -31,12 +39,12 @@ | |
| 39 | {% endif %} |
| 40 | </td> |
| 41 | </tr> |
| 42 | {% empty %} |
| 43 | <tr> |
| 44 | <td colspan="6" class="px-6 py-8 text-center text-sm text-gray-400">No members found.</td> |
| 45 | </tr> |
| 46 | {% endfor %} |
| 47 | </tbody> |
| 48 | </table> |
| 49 | </div> |
| 50 | </div> |
| 51 | |
| 52 | DDED templates/organization/role_detail.html |
| 53 | DDED templates/organization/role_list.html |
| --- a/templates/organization/role_detail.html | ||
| +++ b/templates/organization/role_detail.html | ||
| @@ -0,0 +1,71 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ role.name }} Role — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:role_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Roles</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> | |
| 10 | + <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between"> | |
| 11 | + <div> | |
| 12 | + <h1 class="text-2xl font-bold text-gray-100">{{ role.name }}</h1> | |
| 13 | + <p class="mt-1 text-sm text-gray-400">{{ role.description|default:"No description." }}</p> | |
| 14 | + </div> | |
| 15 | + <div class="mt-4 flex items-center gap-2 sm:mt-0"> | |
| 16 | + {% if role.is_default %} | |
| 17 | + <span class="inline-flex rounded-full bg-blue-900/50 px-2 text-xs font-semibold leading-5 text-blue-300">Default Role</span> | |
| 18 | + {% endif %} | |
| 19 | + </div> | |
| 20 | + </div> | |
| 21 | + | |
| 22 | + <div class="border-t border-gray-700 px-6 py-5"> | |
| 23 | + <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> | |
| 24 | + <div> | |
| 25 | + <dt class="text-sm font-me leading-5 text-blue-300">Default Role</span> | |
| 26 | + {% endif %} | |
| 27 | + ay-400 font-mono">{{ role.slug }}</dd> | |
| 28 | + </div> | |
| 29 | + <div> | |
| 30 | + <dt class="text-sm font-medium text-gray-400">GUID</dt> | |
| 31 | + <dd class="mt-1 text-sm text-gray-400 font-mono">{{ role.guid }}</dd> | |
| 32 | + </div> | |
| 33 | + <div> | |
| 34 | + <dt class="text-sm font-medium text-gray-400">Created</dt> | |
| 35 | + <dd class="mt-1 text-sm text-gray-400">{{ role.created_at|date:"N j, Y g:i a" }}</dd> | |
| 36 | + </div> | |
| 37 | + <div> | |
| 38 | + <dt class="text-sm font-medium text-gray-400">Updated</dt> | |
| 39 | + <dd class="mt-1 text-sm text-gray-400">{{ role.updated_at|date:"N j, Y g:i a" }}</dd> | |
| 40 | + </div> | |
| 41 | + </dl> | |
| 42 | + </div> | |
| 43 | +</div> | |
| 44 | + | |
| 45 | +<div class="mt-8"> | |
| 46 | + <h2 class="text-lg font-semibold text-gray-100 mb-4">Permissions</h2> | |
| 47 | + {% if grouped_permissions %} | |
| 48 | + <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> | |
| 49 | + {% for category, perms in grouped_permissions.items %} | |
| 50 | + <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 51 | + <div class="bg-gray-900 px-4 py-3"> | |
| 52 | + <h3 class="text-sm font-semibold text-gray-200">{{ category }}</h3> | |
| 53 | + </div> | |
| 54 | + <ul class="divide-y divide-gray-700"> | |
| 55 | + {% for perm in perms %} | |
| 56 | + <li class="px-4 py-2 text-sm text-gray-400"> | |
| 57 | + <span class="font-mono text-xs">{{ perm.codename }}</span> | |
| 58 | + <span class="ml-2 text-gray-500">{{ perm.name }}</span> | |
| 59 | + </li> | |
| 60 | + {% endfor %} | |
| 61 | + </ul> | |
| 62 | + </div> | |
| 63 | + {% endfor %} | |
| 64 | + </div> | |
| 65 | + {% else %} | |
| 66 | + <p class="text-sm text-gray-400">No permissions assigned to this role.</p> | |
| 67 | + {% endif %} | |
| 68 | +</div> | |
| 69 | + | |
| 70 | +<div class="mt-8"> | |
| 71 | + <h2 class="text-lg font-semibold text-gray-100 mb-4">Members with this R |
| --- a/templates/organization/role_detail.html | |
| +++ b/templates/organization/role_detail.html | |
| @@ -0,0 +1,71 @@ | |
| --- a/templates/organization/role_detail.html | |
| +++ b/templates/organization/role_detail.html | |
| @@ -0,0 +1,71 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ role.name }} Role — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:role_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Roles</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 10 | <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between"> |
| 11 | <div> |
| 12 | <h1 class="text-2xl font-bold text-gray-100">{{ role.name }}</h1> |
| 13 | <p class="mt-1 text-sm text-gray-400">{{ role.description|default:"No description." }}</p> |
| 14 | </div> |
| 15 | <div class="mt-4 flex items-center gap-2 sm:mt-0"> |
| 16 | {% if role.is_default %} |
| 17 | <span class="inline-flex rounded-full bg-blue-900/50 px-2 text-xs font-semibold leading-5 text-blue-300">Default Role</span> |
| 18 | {% endif %} |
| 19 | </div> |
| 20 | </div> |
| 21 | |
| 22 | <div class="border-t border-gray-700 px-6 py-5"> |
| 23 | <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> |
| 24 | <div> |
| 25 | <dt class="text-sm font-me leading-5 text-blue-300">Default Role</span> |
| 26 | {% endif %} |
| 27 | ay-400 font-mono">{{ role.slug }}</dd> |
| 28 | </div> |
| 29 | <div> |
| 30 | <dt class="text-sm font-medium text-gray-400">GUID</dt> |
| 31 | <dd class="mt-1 text-sm text-gray-400 font-mono">{{ role.guid }}</dd> |
| 32 | </div> |
| 33 | <div> |
| 34 | <dt class="text-sm font-medium text-gray-400">Created</dt> |
| 35 | <dd class="mt-1 text-sm text-gray-400">{{ role.created_at|date:"N j, Y g:i a" }}</dd> |
| 36 | </div> |
| 37 | <div> |
| 38 | <dt class="text-sm font-medium text-gray-400">Updated</dt> |
| 39 | <dd class="mt-1 text-sm text-gray-400">{{ role.updated_at|date:"N j, Y g:i a" }}</dd> |
| 40 | </div> |
| 41 | </dl> |
| 42 | </div> |
| 43 | </div> |
| 44 | |
| 45 | <div class="mt-8"> |
| 46 | <h2 class="text-lg font-semibold text-gray-100 mb-4">Permissions</h2> |
| 47 | {% if grouped_permissions %} |
| 48 | <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 49 | {% for category, perms in grouped_permissions.items %} |
| 50 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 51 | <div class="bg-gray-900 px-4 py-3"> |
| 52 | <h3 class="text-sm font-semibold text-gray-200">{{ category }}</h3> |
| 53 | </div> |
| 54 | <ul class="divide-y divide-gray-700"> |
| 55 | {% for perm in perms %} |
| 56 | <li class="px-4 py-2 text-sm text-gray-400"> |
| 57 | <span class="font-mono text-xs">{{ perm.codename }}</span> |
| 58 | <span class="ml-2 text-gray-500">{{ perm.name }}</span> |
| 59 | </li> |
| 60 | {% endfor %} |
| 61 | </ul> |
| 62 | </div> |
| 63 | {% endfor %} |
| 64 | </div> |
| 65 | {% else %} |
| 66 | <p class="text-sm text-gray-400">No permissions assigned to this role.</p> |
| 67 | {% endif %} |
| 68 | </div> |
| 69 | |
| 70 | <div class="mt-8"> |
| 71 | <h2 class="text-lg font-semibold text-gray-100 mb-4">Members with this R |
| --- a/templates/organization/role_list.html | ||
| +++ b/templates/organization/role_list.html | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Roles — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="md:flex md:items-center md:justify-between mb-6"> | |
| 10 | + <h1 class="text-2xl font-bold t{% if not roles %} | |
| 11 | + {% if not roles %} | |
| 12 | + {% if perms.organization.change_organizationr user.is_superuser %} | |
| 13 | + <form method="post" action="{% url 'organization{% csrf_token %} | |
| 14 | + <button type="submit" | |
| 15 | + class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hoInitialize Rol </form> | |
| 16 | + {% {% endif %}a> | |
| 17 | + {% endif %} | |
| 18 | + </div> | |
| 19 | +</div> | |
| 20 | + | |
| 21 | +{% if roles %} | |
| 22 | +<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> | |
| 23 | + {% for role in roles %} | |
| 24 | + <a href="{% url 'organization:role_detail' slug=role.slug %}" | |
| 25 | + class="group block overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700 hover:border-brand p-5 transition-colors"> | |
| 26 | + <div class="flex items-start justify-between"> | |
| 27 | + <div> | |
| 28 | + <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand-light">{{ role.name }}</h3> | |
| 29 | + <p class="mt-1 text-sm text-gray-400">{{ role.description|default:"No description." }}</p> | |
| 30 | + </div> | |
| 31 | + {% if role.is_default %} | |
| 32 | + <span class="inline-flex rounded-full bg-blue-900/50 px-2 text-xs font-semibold leading-5 text-blue-300">Default</span> | |
| 33 | + {% endif %} | |
| 34 | + </div> | |
| 35 | + <div class="mt-4 flex items-center gap-4 text-sm text-gray-400"> | |
| 36 | + <span class="flex items-center gap-1"> | |
| 37 | + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 38 | + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.7 |
| --- a/templates/organization/role_list.html | |
| +++ b/templates/organization/role_list.html | |
| @@ -0,0 +1,38 @@ | |
| --- a/templates/organization/role_list.html | |
| +++ b/templates/organization/role_list.html | |
| @@ -0,0 +1,38 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Roles — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 10 | <h1 class="text-2xl font-bold t{% if not roles %} |
| 11 | {% if not roles %} |
| 12 | {% if perms.organization.change_organizationr user.is_superuser %} |
| 13 | <form method="post" action="{% url 'organization{% csrf_token %} |
| 14 | <button type="submit" |
| 15 | class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hoInitialize Rol </form> |
| 16 | {% {% endif %}a> |
| 17 | {% endif %} |
| 18 | </div> |
| 19 | </div> |
| 20 | |
| 21 | {% if roles %} |
| 22 | <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 23 | {% for role in roles %} |
| 24 | <a href="{% url 'organization:role_detail' slug=role.slug %}" |
| 25 | class="group block overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700 hover:border-brand p-5 transition-colors"> |
| 26 | <div class="flex items-start justify-between"> |
| 27 | <div> |
| 28 | <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand-light">{{ role.name }}</h3> |
| 29 | <p class="mt-1 text-sm text-gray-400">{{ role.description|default:"No description." }}</p> |
| 30 | </div> |
| 31 | {% if role.is_default %} |
| 32 | <span class="inline-flex rounded-full bg-blue-900/50 px-2 text-xs font-semibold leading-5 text-blue-300">Default</span> |
| 33 | {% endif %} |
| 34 | </div> |
| 35 | <div class="mt-4 flex items-center gap-4 text-sm text-gray-400"> |
| 36 | <span class="flex items-center gap-1"> |
| 37 | <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 38 | <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.7 |
| --- templates/organization/settings.html | ||
| +++ templates/organization/settings.html | ||
| @@ -68,10 +68,24 @@ | ||
| 68 | 68 | {% if perms.organization.view_team %} |
| 69 | 69 | <a href="{% url 'organization:team_list' %}" |
| 70 | 70 | class="inline-flex items-center rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 71 | 71 | Manage Teams |
| 72 | 72 | </a> |
| 73 | + {% endif %} | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | +</div> | |
| 77 | + | |
| 78 | +<div class="mt-4"> | |
| 79 | + <div class="md:flex md:items-center md:justify-between mb-4"> | |
| 80 | + <h2 class="text-lg font-semibold text-gray-100">Roles</h2> | |
| 81 | + <div class="flex gap-3 mt-4 md:mt-0"> | |
| 82 | + {% if perms.organization.view_organization %} | |
| 83 | + <a href="{% url 'organization:role_list' %}" | |
| 84 | + class="inline-flex items-center rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 85 | + Manage Roles | |
| 86 | + </a> | |
| 73 | 87 | {% endif %} |
| 74 | 88 | </div> |
| 75 | 89 | </div> |
| 76 | 90 | </div> |
| 77 | 91 | {% endblock %} |
| 78 | 92 |
| --- templates/organization/settings.html | |
| +++ templates/organization/settings.html | |
| @@ -68,10 +68,24 @@ | |
| 68 | {% if perms.organization.view_team %} |
| 69 | <a href="{% url 'organization:team_list' %}" |
| 70 | class="inline-flex items-center rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 71 | Manage Teams |
| 72 | </a> |
| 73 | {% endif %} |
| 74 | </div> |
| 75 | </div> |
| 76 | </div> |
| 77 | {% endblock %} |
| 78 |
| --- templates/organization/settings.html | |
| +++ templates/organization/settings.html | |
| @@ -68,10 +68,24 @@ | |
| 68 | {% if perms.organization.view_team %} |
| 69 | <a href="{% url 'organization:team_list' %}" |
| 70 | class="inline-flex items-center rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 71 | Manage Teams |
| 72 | </a> |
| 73 | {% endif %} |
| 74 | </div> |
| 75 | </div> |
| 76 | </div> |
| 77 | |
| 78 | <div class="mt-4"> |
| 79 | <div class="md:flex md:items-center md:justify-between mb-4"> |
| 80 | <h2 class="text-lg font-semibold text-gray-100">Roles</h2> |
| 81 | <div class="flex gap-3 mt-4 md:mt-0"> |
| 82 | {% if perms.organization.view_organization %} |
| 83 | <a href="{% url 'organization:role_list' %}" |
| 84 | class="inline-flex items-center rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 85 | Manage Roles |
| 86 | </a> |
| 87 | {% endif %} |
| 88 | </div> |
| 89 | </div> |
| 90 | </div> |
| 91 | {% endblock %} |
| 92 |
| --- templates/organization/user_detail.html | ||
| +++ templates/organization/user_detail.html | ||
| @@ -68,10 +68,21 @@ | ||
| 68 | 68 | {% if membership %} |
| 69 | 69 | <div> |
| 70 | 70 | <dt class="text-sm font-medium text-gray-400">Member Since</dt> |
| 71 | 71 | <dd class="mt-1 text-sm text-gray-400">{{ membership.created_at|date:"N j, Y g:i a" }}</dd> |
| 72 | 72 | </div> |
| 73 | + <div> | |
| 74 | + <dt class="text-sm font-medium text-gray-400">Role</dt> | |
| 75 | + <dd class="mt-1"> | |
| 76 | + {% if membership.role %} | |
| 77 | + <a href="{% url 'organization:role_detail' slug=membership.role.slug %}" class="inline-flex rounded-full bg-purple-900/50 px-2 text-xs font-semibold leading-5 text-purple-300 hover:text-purple-200">{{ membership.role.name }}</a> | |
| 78 | + <p class="mt-1 text-xs text-gray-500">{{ membership.role.description }}</p> | |
| 79 | + {% else %} | |
| 80 | + <span class="text-sm text-gray-500">No role assigned</span> | |
| 81 | + {% endif %} | |
| 82 | + </dd> | |
| 83 | + </div> | |
| 73 | 84 | {% endif %} |
| 74 | 85 | </dl> |
| 75 | 86 | </div> |
| 76 | 87 | </div> |
| 77 | 88 | |
| 78 | 89 | |
| 79 | 90 | ADDED templates/projects/group_confirm_delete.html |
| 80 | 91 | ADDED templates/projects/group_detail.html |
| 81 | 92 | ADDED templates/projects/group_form.html |
| 82 | 93 | ADDED templates/projects/group_list.html |
| 83 | 94 | ADDED templates/projects/partials/group_table.html |
| 84 | 95 | ADDED tests/test_project_groups.py |
| 85 | 96 | ADDED tests/test_roles.py |
| --- templates/organization/user_detail.html | |
| +++ templates/organization/user_detail.html | |
| @@ -68,10 +68,21 @@ | |
| 68 | {% if membership %} |
| 69 | <div> |
| 70 | <dt class="text-sm font-medium text-gray-400">Member Since</dt> |
| 71 | <dd class="mt-1 text-sm text-gray-400">{{ membership.created_at|date:"N j, Y g:i a" }}</dd> |
| 72 | </div> |
| 73 | {% endif %} |
| 74 | </dl> |
| 75 | </div> |
| 76 | </div> |
| 77 | |
| 78 | |
| 79 | DDED templates/projects/group_confirm_delete.html |
| 80 | DDED templates/projects/group_detail.html |
| 81 | DDED templates/projects/group_form.html |
| 82 | DDED templates/projects/group_list.html |
| 83 | DDED templates/projects/partials/group_table.html |
| 84 | DDED tests/test_project_groups.py |
| 85 | DDED tests/test_roles.py |
| --- templates/organization/user_detail.html | |
| +++ templates/organization/user_detail.html | |
| @@ -68,10 +68,21 @@ | |
| 68 | {% if membership %} |
| 69 | <div> |
| 70 | <dt class="text-sm font-medium text-gray-400">Member Since</dt> |
| 71 | <dd class="mt-1 text-sm text-gray-400">{{ membership.created_at|date:"N j, Y g:i a" }}</dd> |
| 72 | </div> |
| 73 | <div> |
| 74 | <dt class="text-sm font-medium text-gray-400">Role</dt> |
| 75 | <dd class="mt-1"> |
| 76 | {% if membership.role %} |
| 77 | <a href="{% url 'organization:role_detail' slug=membership.role.slug %}" class="inline-flex rounded-full bg-purple-900/50 px-2 text-xs font-semibold leading-5 text-purple-300 hover:text-purple-200">{{ membership.role.name }}</a> |
| 78 | <p class="mt-1 text-xs text-gray-500">{{ membership.role.description }}</p> |
| 79 | {% else %} |
| 80 | <span class="text-sm text-gray-500">No role assigned</span> |
| 81 | {% endif %} |
| 82 | </dd> |
| 83 | </div> |
| 84 | {% endif %} |
| 85 | </dl> |
| 86 | </div> |
| 87 | </div> |
| 88 | |
| 89 | |
| 90 | DDED templates/projects/group_confirm_delete.html |
| 91 | DDED templates/projects/group_detail.html |
| 92 | DDED templates/projects/group_form.html |
| 93 | DDED templates/projects/group_list.html |
| 94 | DDED templates/projects/partials/group_table.html |
| 95 | DDED tests/test_project_groups.py |
| 96 | DDED tests/test_roles.py |
| --- a/templates/projects/group_confirm_delete.html | ||
| +++ b/templates/projects/group_confirm_delete.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Delete {{ group.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'projects:group_detail' slug=group.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ group.name }}</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-lg"> | |
| 10 | + <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 11 | + <h2 class="text-lg font-semibold text-gray-100">Delete Group</h2> | |
| 12 | + <p class="mt-2 text-sm text-gray-400"> | |
| 13 | + Are you sure you want to delete <strong class="text-gray-100">{{ group.name }}</strong>? Projects in this group will be unlinked but not deleted. | |
| 14 | + </p> | |
| 15 | + <form method="post" class="mt-6 flex justify-end gap-3"> | |
| 16 | + {% csrf_token %} | |
| 17 | + <a href="{% url 'projects:group_detail' slug=group.slug %}" | |
| 18 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 19 | + Cancel | |
| 20 | + </a> | |
| 21 | + <button type="submit" | |
| 22 | + class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 23 | + Delete | |
| 24 | + </button> | |
| 25 | + </form> | |
| 26 | + </div> | |
| 27 | +</div> | |
| 28 | +{% endblock %} |
| --- a/templates/projects/group_confirm_delete.html | |
| +++ b/templates/projects/group_confirm_delete.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/projects/group_confirm_delete.html | |
| +++ b/templates/projects/group_confirm_delete.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Delete {{ group.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'projects:group_detail' slug=group.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ group.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-lg"> |
| 10 | <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 11 | <h2 class="text-lg font-semibold text-gray-100">Delete Group</h2> |
| 12 | <p class="mt-2 text-sm text-gray-400"> |
| 13 | Are you sure you want to delete <strong class="text-gray-100">{{ group.name }}</strong>? Projects in this group will be unlinked but not deleted. |
| 14 | </p> |
| 15 | <form method="post" class="mt-6 flex justify-end gap-3"> |
| 16 | {% csrf_token %} |
| 17 | <a href="{% url 'projects:group_detail' slug=group.slug %}" |
| 18 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 19 | Cancel |
| 20 | </a> |
| 21 | <button type="submit" |
| 22 | class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 23 | Delete |
| 24 | </button> |
| 25 | </form> |
| 26 | </div> |
| 27 | </div> |
| 28 | {% endblock %} |
| --- a/templates/projects/group_detail.html | ||
| +++ b/templates/projects/group_detail.html | ||
| @@ -0,0 +1,78 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ group.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'projects:group_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Groups</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="flex items-center justify-between mb-6"> | |
| 10 | + <div> | |
| 11 | + <h1 class="text-2xl font-bold text-gray-100">{{ group.name }}</h1> | |
| 12 | + <p class="mt-1 text-sm text-gray-400">{{ group.description|default:"No description." }}</p> | |
| 13 | + </div> | |
| 14 | + <div class="flex gap-3"> | |
| 15 | + {% if perms.projects.change_projectgroup %} | |
| 16 | + <a href="{% url 'projects:group_edit' slug=group.slug %}" | |
| 17 | + class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 18 | + Edit | |
| 19 | + </a> | |
| 20 | + {% endif %} | |
| 21 | + {% if perms.projects.delete_projectgroup %} | |
| 22 | + <a href="{% url 'projects:group_delete' slug=group.slug %}" | |
| 23 | + class="rounded-md bg-red-600/20 px-3 py-2 text-sm font-semibold text-red-400 shadow-sm ring-1 ring-inset ring-red-600/50 hover:bg-red-600/30"> | |
| 24 | + Delete | |
| 25 | + </a> | |
| 26 | + {% endif %} | |
| 27 | + </div> | |
| 28 | +</div> | |
| 29 | + | |
| 30 | +<!-- Member projects --> | |
| 31 | +<div class="mb-4"> | |
| 32 | + <h2 class="text-lg font-semibold text-gray-200">Projects in this group</h2> | |
| 33 | +</div> | |
| 34 | + | |
| 35 | +<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> | |
| 36 | + {% for project in group_projects %} | |
| 37 | + <a href="{% url 'projects:detail' slug=project.slug %}" | |
| 38 | + class="group rounded-lg bg-gray-800 border border-gray-700 p-4 hover:border-brand/50 transition-colors"> | |
| 39 | + <div class="flex items-start justify-between"> | |
| 40 | + <h3 class="text-sm font-medium text-gray-100 group-hover:text-brand-light">{{ project.name }}</h3> | |
| 41 | + {% if project.visibility == "public" %} | |
| 42 | + <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Public</span> | |
| 43 | + {% elif project.visibility == "internal" %} | |
| 44 | + <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300">Internal</span> | |
| 45 | + {% else %} | |
| 46 | + <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Private</span> | |
| 47 | + {% endif %} | |
| 48 | + </div> | |
| 49 | + <p class="mt-2 text-xs text-gray-500 line-clamp-2">{{ project.description|default:"No description." }}</p> | |
| 50 | + <div class="mt-3 text-xs text-gray-500">Created {{ project.created_at|date:"N j, Y" }}</div> | |
| 51 | + </a> | |
| 52 | + {% empty %} | |
| 53 | + <div class="col-span-full rounded-lg bg-gray-800 border border-gray-700 p-8 text-center"> | |
| 54 | + <p class="text-sm text-gray-400">No projects in this group yet.</p> | |
| 55 | + {% if perms.projects.add_project %} | |
| 56 | + <a href="{% url 'projects:create' %}" class="mt-2 inline-block text-sm text-brand-light hover:text-brand">Create a project</a> | |
| 57 | + {% endif %} | |
| 58 | + </div> | |
| 59 | + {% endfor %} | |
| 60 | +</div> | |
| 61 | + | |
| 62 | +<!-- Group info --> | |
| 63 | +<div class="mt-6"> | |
| 64 | + <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 max-w-sm"> | |
| 65 | + <h3 class="text-sm font-medium text-gray-300 mb-3">About</h3> | |
| 66 | + <dl class="space-y-2 text-sm"> | |
| 67 | + <div class="flex items-center justify-between"> | |
| 68 | + <dt class="text-gray-500">Projects</dt> | |
| 69 | + <dd class="text-gray-300">{{ group_projects|length }}</dd> | |
| 70 | + </div> | |
| 71 | + <div class="flex items-center justify-between"> | |
| 72 | + <dt class="text-gray-500">Created</dt> | |
| 73 | + <dd class="text-gray-300">{{ group.created_at|date:"Y-m-d" }}</dd> | |
| 74 | + </div> | |
| 75 | + </dl> | |
| 76 | + </div> | |
| 77 | +</div> | |
| 78 | +{% endblock %} |
| --- a/templates/projects/group_detail.html | |
| +++ b/templates/projects/group_detail.html | |
| @@ -0,0 +1,78 @@ | |
| --- a/templates/projects/group_detail.html | |
| +++ b/templates/projects/group_detail.html | |
| @@ -0,0 +1,78 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ group.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'projects:group_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Groups</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | <div> |
| 11 | <h1 class="text-2xl font-bold text-gray-100">{{ group.name }}</h1> |
| 12 | <p class="mt-1 text-sm text-gray-400">{{ group.description|default:"No description." }}</p> |
| 13 | </div> |
| 14 | <div class="flex gap-3"> |
| 15 | {% if perms.projects.change_projectgroup %} |
| 16 | <a href="{% url 'projects:group_edit' slug=group.slug %}" |
| 17 | class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 18 | Edit |
| 19 | </a> |
| 20 | {% endif %} |
| 21 | {% if perms.projects.delete_projectgroup %} |
| 22 | <a href="{% url 'projects:group_delete' slug=group.slug %}" |
| 23 | class="rounded-md bg-red-600/20 px-3 py-2 text-sm font-semibold text-red-400 shadow-sm ring-1 ring-inset ring-red-600/50 hover:bg-red-600/30"> |
| 24 | Delete |
| 25 | </a> |
| 26 | {% endif %} |
| 27 | </div> |
| 28 | </div> |
| 29 | |
| 30 | <!-- Member projects --> |
| 31 | <div class="mb-4"> |
| 32 | <h2 class="text-lg font-semibold text-gray-200">Projects in this group</h2> |
| 33 | </div> |
| 34 | |
| 35 | <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> |
| 36 | {% for project in group_projects %} |
| 37 | <a href="{% url 'projects:detail' slug=project.slug %}" |
| 38 | class="group rounded-lg bg-gray-800 border border-gray-700 p-4 hover:border-brand/50 transition-colors"> |
| 39 | <div class="flex items-start justify-between"> |
| 40 | <h3 class="text-sm font-medium text-gray-100 group-hover:text-brand-light">{{ project.name }}</h3> |
| 41 | {% if project.visibility == "public" %} |
| 42 | <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Public</span> |
| 43 | {% elif project.visibility == "internal" %} |
| 44 | <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300">Internal</span> |
| 45 | {% else %} |
| 46 | <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Private</span> |
| 47 | {% endif %} |
| 48 | </div> |
| 49 | <p class="mt-2 text-xs text-gray-500 line-clamp-2">{{ project.description|default:"No description." }}</p> |
| 50 | <div class="mt-3 text-xs text-gray-500">Created {{ project.created_at|date:"N j, Y" }}</div> |
| 51 | </a> |
| 52 | {% empty %} |
| 53 | <div class="col-span-full rounded-lg bg-gray-800 border border-gray-700 p-8 text-center"> |
| 54 | <p class="text-sm text-gray-400">No projects in this group yet.</p> |
| 55 | {% if perms.projects.add_project %} |
| 56 | <a href="{% url 'projects:create' %}" class="mt-2 inline-block text-sm text-brand-light hover:text-brand">Create a project</a> |
| 57 | {% endif %} |
| 58 | </div> |
| 59 | {% endfor %} |
| 60 | </div> |
| 61 | |
| 62 | <!-- Group info --> |
| 63 | <div class="mt-6"> |
| 64 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 max-w-sm"> |
| 65 | <h3 class="text-sm font-medium text-gray-300 mb-3">About</h3> |
| 66 | <dl class="space-y-2 text-sm"> |
| 67 | <div class="flex items-center justify-between"> |
| 68 | <dt class="text-gray-500">Projects</dt> |
| 69 | <dd class="text-gray-300">{{ group_projects|length }}</dd> |
| 70 | </div> |
| 71 | <div class="flex items-center justify-between"> |
| 72 | <dt class="text-gray-500">Created</dt> |
| 73 | <dd class="text-gray-300">{{ group.created_at|date:"Y-m-d" }}</dd> |
| 74 | </div> |
| 75 | </dl> |
| 76 | </div> |
| 77 | </div> |
| 78 | {% endblock %} |
| --- a/templates/projects/group_form.html | ||
| +++ b/templates/projects/group_form.html | ||
| @@ -0,0 +1,50 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ title }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + {% if group %} | |
| 7 | + <a href="{% url 'projects:group_detail' slug=group.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ group.name }}</a> | |
| 8 | + {% else %} | |
| 9 | + <a href="{% url 'projects:group_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Groups</a> | |
| 10 | + {% endif %} | |
| 11 | +</div> | |
| 12 | + | |
| 13 | +<div class="mx-auto max-w-2xl"> | |
| 14 | + <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> | |
| 15 | + | |
| 16 | + <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 17 | + {% csrf_token %} | |
| 18 | + | |
| 19 | + {% for field in form %} | |
| 20 | + <div> | |
| 21 | + <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 22 | + {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 23 | + </label> | |
| 24 | + <div class="mt-1">{{ field }}</div> | |
| 25 | + {% if field.errors %} | |
| 26 | + <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 27 | + {% endif %} | |
| 28 | + </div> | |
| 29 | + {% endfor %} | |
| 30 | + | |
| 31 | + <div class="flex justify-end gap-3 pt-4"> | |
| 32 | + {% if group %} | |
| 33 | + <a href="{% url 'projects:group_detail' slug=group.slug %}" | |
| 34 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 35 | + Cancel | |
| 36 | + </a> | |
| 37 | + {% else %} | |
| 38 | + <a href="{% url 'projects:group_list' %}" | |
| 39 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 40 | + Cancel | |
| 41 | + </a> | |
| 42 | + {% endif %} | |
| 43 | + <button type="submit" | |
| 44 | + class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 45 | + {% if group %}Update{% else %}Create{% endif %} | |
| 46 | + </button> | |
| 47 | + </div> | |
| 48 | + </form> | |
| 49 | +</div> | |
| 50 | +{% endblock %} |
| --- a/templates/projects/group_form.html | |
| +++ b/templates/projects/group_form.html | |
| @@ -0,0 +1,50 @@ | |
| --- a/templates/projects/group_form.html | |
| +++ b/templates/projects/group_form.html | |
| @@ -0,0 +1,50 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ title }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | {% if group %} |
| 7 | <a href="{% url 'projects:group_detail' slug=group.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ group.name }}</a> |
| 8 | {% else %} |
| 9 | <a href="{% url 'projects:group_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Groups</a> |
| 10 | {% endif %} |
| 11 | </div> |
| 12 | |
| 13 | <div class="mx-auto max-w-2xl"> |
| 14 | <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> |
| 15 | |
| 16 | <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 17 | {% csrf_token %} |
| 18 | |
| 19 | {% for field in form %} |
| 20 | <div> |
| 21 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 22 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 23 | </label> |
| 24 | <div class="mt-1">{{ field }}</div> |
| 25 | {% if field.errors %} |
| 26 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 27 | {% endif %} |
| 28 | </div> |
| 29 | {% endfor %} |
| 30 | |
| 31 | <div class="flex justify-end gap-3 pt-4"> |
| 32 | {% if group %} |
| 33 | <a href="{% url 'projects:group_detail' slug=group.slug %}" |
| 34 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 35 | Cancel |
| 36 | </a> |
| 37 | {% else %} |
| 38 | <a href="{% url 'projects:group_list' %}" |
| 39 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 40 | Cancel |
| 41 | </a> |
| 42 | {% endif %} |
| 43 | <button type="submit" |
| 44 | class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 45 | {% if group %}Update{% else %}Create{% endif %} |
| 46 | </button> |
| 47 | </div> |
| 48 | </form> |
| 49 | </div> |
| 50 | {% endblock %} |
| --- a/templates/projects/group_list.html | ||
| +++ b/templates/projects/group_list.html | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Project Groups — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="md:flex md:items-center md:justify-between mb-6"> | |
| 6 | + <h1 class="text-2xl font-bold text-gray-100">Project Groups</h1> | |
| 7 | + {% if perms.projects.add_projectgroup %} | |
| 8 | + <a href="{% url 'projects:group_create' %}" | |
| 9 | + class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 10 | + New Group | |
| 11 | + </a> | |
| 12 | + {% endif %} | |
| 13 | +</div> | |
| 14 | + | |
| 15 | +{% include "projects/partiaendblock %} |
| --- a/templates/projects/group_list.html | |
| +++ b/templates/projects/group_list.html | |
| @@ -0,0 +1,15 @@ | |
| --- a/templates/projects/group_list.html | |
| +++ b/templates/projects/group_list.html | |
| @@ -0,0 +1,15 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Project Groups — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 6 | <h1 class="text-2xl font-bold text-gray-100">Project Groups</h1> |
| 7 | {% if perms.projects.add_projectgroup %} |
| 8 | <a href="{% url 'projects:group_create' %}" |
| 9 | class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 10 | New Group |
| 11 | </a> |
| 12 | {% endif %} |
| 13 | </div> |
| 14 | |
| 15 | {% include "projects/partiaendblock %} |
| --- a/templates/projects/partials/group_table.html | ||
| +++ b/templates/projects/partials/group_table.html | ||
| @@ -0,0 +1,27 @@ | ||
| 1 | +<div id="group-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 2 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 3 | + <thead class="bg-gray-900"> | |
| 4 | + <tr> | |
| 5 | + <t">Created</th> | |
| 6 | + uppercase tracking-wider text-gray-400">Name</th> | |
| 7 | + <th class="px-6 py-3 text-left textext-gray-400">Description</th> | |
| 8 | + <th class="px-6 py-3 text-left text bg-gray-800"> | |
| 9 | + {% for group in groups %} | |
| 10 | + <tr class="hover:bg-gray-700/50"le"> | |
| 11 | + <div class="overflow-x-aut<div id="group-table"> | |
| 12 | + <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 13 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 14 | + <thead class="bg-gray-900/80"> | |
| 15 | + <tr> | |
| 16 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Name</th> | |
| 17 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Description</th> | |
| 18 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Projects</th> | |
| 19 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Created</th> | |
| 20 | + <th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-400">Actions</th> | |
| 21 | + </tr> | |
| 22 | + </thead> | |
| 23 | + <tbody class="divide-y divide-gray-700/70 bg-gray-800"> | |
| 24 | + {% for group in groups %} | |
| 25 | + <tr class="hover:bg-gray-700/40 transition-colors"> | |
| 26 | + <td class="px-6 py-4 whitespace-nowrap"> | |
| 27 | + |
| --- a/templates/projects/partials/group_table.html | |
| +++ b/templates/projects/partials/group_table.html | |
| @@ -0,0 +1,27 @@ | |
| --- a/templates/projects/partials/group_table.html | |
| +++ b/templates/projects/partials/group_table.html | |
| @@ -0,0 +1,27 @@ | |
| 1 | <div id="group-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 2 | <table class="min-w-full divide-y divide-gray-700"> |
| 3 | <thead class="bg-gray-900"> |
| 4 | <tr> |
| 5 | <t">Created</th> |
| 6 | uppercase tracking-wider text-gray-400">Name</th> |
| 7 | <th class="px-6 py-3 text-left textext-gray-400">Description</th> |
| 8 | <th class="px-6 py-3 text-left text bg-gray-800"> |
| 9 | {% for group in groups %} |
| 10 | <tr class="hover:bg-gray-700/50"le"> |
| 11 | <div class="overflow-x-aut<div id="group-table"> |
| 12 | <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 13 | <table class="min-w-full divide-y divide-gray-700"> |
| 14 | <thead class="bg-gray-900/80"> |
| 15 | <tr> |
| 16 | <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Name</th> |
| 17 | <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Description</th> |
| 18 | <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Projects</th> |
| 19 | <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Created</th> |
| 20 | <th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-400">Actions</th> |
| 21 | </tr> |
| 22 | </thead> |
| 23 | <tbody class="divide-y divide-gray-700/70 bg-gray-800"> |
| 24 | {% for group in groups %} |
| 25 | <tr class="hover:bg-gray-700/40 transition-colors"> |
| 26 | <td class="px-6 py-4 whitespace-nowrap"> |
| 27 |
| --- a/tests/test_project_groups.py | ||
| +++ b/tests/test_project_groups.py | ||
| @@ -0,0 +1,347 @@ | ||
| 1 | +"""Tests for Project Groups: model, views, sidebar context, and permissions.""" | |
| 2 | + | |
| 3 | +import pytest | |
| 4 | +from django.contrib.auth.models import Group, Permission | |
| 5 | +from django.test import Client | |
| 6 | + | |
| 7 | +from projects.models import Project, ProjectGroup | |
| 8 | + | |
| 9 | + | |
| 10 | +@pytest.fixture | |
| 11 | +def sample_group(db, admin_user): | |
| 12 | + return ProjectGroup.objects.create(name="Fossil SCM", description="The Fossil repos", created_by=admin_user) | |
| 13 | + | |
| 14 | + | |
| 15 | +@pytest.fixture | |
| 16 | +def editor_user(db): | |
| 17 | + """User with full project and projectgroup permissions (add/change/delete/view) but not superuser.""" | |
| 18 | + user = __import__("django.contrib.auth.models", fromlist=["User"]).User.objects.create_user( | |
| 19 | + username="editor", email="[email protected]", password="testpass123" | |
| 20 | + ) | |
| 21 | + group, _ = Group.objects.get_or_create(name="Editors") | |
| 22 | + perms = Permission.objects.filter( | |
| 23 | + content_type__app_label="projects", | |
| 24 | + ) | |
| 25 | + group.permissions.set(perms) | |
| 26 | + user.groups.add(group) | |
| 27 | + return user | |
| 28 | + | |
| 29 | + | |
| 30 | +@pytest.fixture | |
| 31 | +def editor_client(editor_user): | |
| 32 | + client = Client() | |
| 33 | + client.login(username="editor", password="testpass123") | |
| 34 | + return client | |
| 35 | + | |
| 36 | + | |
| 37 | +# --- Model Tests --- | |
| 38 | + | |
| 39 | + | |
| 40 | +@pytest.mark.django_db | |
| 41 | +class TestProjectGroupModel: | |
| 42 | + def test_create_group(self, admin_user): | |
| 43 | + group = ProjectGroup.objects.create(name="Test Group", created_by=admin_user) | |
| 44 | + assert group.slug == "test-group" | |
| 45 | + assert str(group) == "Test Group" | |
| 46 | + assert group.guid is not None | |
| 47 | + | |
| 48 | + def test_slug_auto_generated(self, admin_user): | |
| 49 | + group = ProjectGroup.objects.create(name="My Cool Group", created_by=admin_user) | |
| 50 | + assert group.slug == "my-cool-group" | |
| 51 | + | |
| 52 | + def test_slug_uniqueness(self, admin_user): | |
| 53 | + ProjectGroup.objects.create(name="Dupes", created_by=admin_user) | |
| 54 | + g2 = ProjectGroup.objects.create(name="Dupes", created_by=admin_user) | |
| 55 | + assert g2.slug == "dupes-1" | |
| 56 | + | |
| 57 | + def test_soft_delete(self, admin_user): | |
| 58 | + group = ProjectGroup.objects.create(name="Deletable", created_by=admin_user) | |
| 59 | + group.soft_delete(user=admin_user) | |
| 60 | + assert group.is_deleted | |
| 61 | + assert ProjectGroup.objects.filter(name="Deletable").count() == 0 | |
| 62 | + assert ProjectGroup.all_objects.filter(name="Deletable").count() == 1 | |
| 63 | + | |
| 64 | + def test_project_group_fk(self, admin_user, org, sample_group): | |
| 65 | + project = Project.objects.create(name="Grouped Project", organization=org, group=sample_group, created_by=admin_user) | |
| 66 | + assert project.group == sample_group | |
| 67 | + assert project in sample_group.projects.all() | |
| 68 | + | |
| 69 | + def test_project_group_nullable(self, admin_user, org): | |
| 70 | + project = Project.objects.create(name="Ungrouped", organization=org, created_by=admin_user) | |
| 71 | + assert project.group is None | |
| 72 | + | |
| 73 | + def test_group_deletion_sets_null(self, admin_user, org, sample_group): | |
| 74 | + project = Project.objects.create(name="Will Unlink", organization=org, group=sample_group, created_by=admin_user) | |
| 75 | + sample_group.delete() | |
| 76 | + project.refresh_from_db() | |
| 77 | + assert project.group is None | |
| 78 | + | |
| 79 | + | |
| 80 | +# --- View Tests: Group List --- | |
| 81 | + | |
| 82 | + | |
| 83 | +@pytest.mark.django_db | |
| 84 | +class TestGroupListView: | |
| 85 | + def test_list_allowed_for_superuser(self, admin_client, sample_group): | |
| 86 | + response = admin_client.get("/projects/groups/") | |
| 87 | + assert response.status_code == 200 | |
| 88 | + assert "Fossil SCM" in response.content.decode() | |
| 89 | + | |
| 90 | + def test_list_allowed_for_viewer(self, viewer_client, sample_group): | |
| 91 | + response = viewer_client.get("/projects/groups/") | |
| 92 | + assert response.status_code == 200 | |
| 93 | + | |
| 94 | + def test_list_denied_for_no_perm(self, no_perm_client): | |
| 95 | + response = no_perm_client.get("/projects/groups/") | |
| 96 | + assert response.status_code == 403 | |
| 97 | + | |
| 98 | + def test_list_denied_for_anon(self, client): | |
| 99 | + response = client.get("/projects/groups/") | |
| 100 | + assert response.status_code == 302 | |
| 101 | + | |
| 102 | + def test_list_shows_project_count(self, admin_client, admin_user, org, sample_group): | |
| 103 | + Project.objects.create(name="P1", organization=org, group=sample_group, created_by=admin_user) | |
| 104 | + Project.objects.create(name="P2", organization=org, group=sample_group, created_by=admin_user) | |
| 105 | + response = admin_client.get("/projects/groups/") | |
| 106 | + content = response.content.decode() | |
| 107 | + assert "Fossil SCM" in content | |
| 108 | + | |
| 109 | + def test_list_htmx_returns_partial(self, admin_client, sample_group): | |
| 110 | + response = admin_client.get("/projects/groups/", HTTP_HX_REQUEST="true") | |
| 111 | + assert response.status_code == 200 | |
| 112 | + content = response.content.decode() | |
| 113 | + assert "Fossil SCM" in content | |
| 114 | + # Partial should not have full page structure | |
| 115 | + assert "<!DOCTYPE html>" not in content | |
| 116 | + | |
| 117 | + | |
| 118 | +# --- View Tests: Group Create --- | |
| 119 | + | |
| 120 | + | |
| 121 | +@pytest.mark.django_db | |
| 122 | +class TestGroupCreateView: | |
| 123 | + def test_create_get_allowed_for_superuser(self, admin_client): | |
| 124 | + response = admin_client.get("/projects/groups/create/") | |
| 125 | + assert response.status_code == 200 | |
| 126 | + | |
| 127 | + def test_create_get_allowed_for_editor(self, editor_client): | |
| 128 | + response = editor_client.get("/projects/groups/create/") | |
| 129 | + assert response.status_code == 200 | |
| 130 | + | |
| 131 | + def test_create_denied_for_viewer(self, viewer_client): | |
| 132 | + response = viewer_client.get("/projects/groups/create/") | |
| 133 | + assert response.status_code == 403 | |
| 134 | + | |
| 135 | + def test_create_denied_for_no_perm(self, no_perm_client): | |
| 136 | + response = no_perm_client.get("/projects/groups/create/") | |
| 137 | + assert response.status_code == 403 | |
| 138 | + | |
| 139 | + def test_create_denied_for_anon(self, client): | |
| 140 | + response = client.get("/projects/groups/create/") | |
| 141 | + assert response.status_code == 302 | |
| 142 | + | |
| 143 | + def test_create_saves_group(self, admin_client, admin_user): | |
| 144 | + response = admin_client.post("/projects/groups/create/", {"name": "New Group", "description": "Desc"}) | |
| 145 | + assert response.status_code == 302 | |
| 146 | + group = ProjectGroup.objects.get(name="New Group") | |
| 147 | + assert group.description == "Desc" | |
| 148 | + assert group.created_by == admin_user | |
| 149 | + | |
| 150 | + def test_create_redirects_to_detail(self, admin_client): | |
| 151 | + response = admin_client.post("/projects/groups/create/", {"name": "Redirect Test"}) | |
| 152 | + assert response.status_code == 302 | |
| 153 | + group = ProjectGroup.objects.get(name="Redirect Test") | |
| 154 | + assert response.url == f"/projects/groups/{group.slug}/" | |
| 155 | + | |
| 156 | + def test_create_requires_name(self, admin_client): | |
| 157 | + response = admin_client.post("/projects/groups/create/", {"name": "", "description": "No name"}) | |
| 158 | + assert response.status_code == 200 # Re-renders form | |
| 159 | + | |
| 160 | + | |
| 161 | +# --- View Tests: Group Detail --- | |
| 162 | + | |
| 163 | + | |
| 164 | +@pytest.mark.django_db | |
| 165 | +class TestGroupDetailView: | |
| 166 | + def test_detail_allowed_for_superuser(self, admin_client, sample_group): | |
| 167 | + response = admin_client.get(f"/projects/groups/{sample_group.slug}/") | |
| 168 | + assert response.status_code == 200 | |
| 169 | + assert "Fossil SCM" in response.content.decode() | |
| 170 | + | |
| 171 | + def test_detail_allowed_for_viewer(self, viewer_client, sample_group): | |
| 172 | + response = viewer_client.get(f"/projects/groups/{sample_group.slug}/") | |
| 173 | + assert response.status_code == 200 | |
| 174 | + | |
| 175 | + def test_detail_denied_for_no_perm(self, no_perm_client, sample_group): | |
| 176 | + response = no_perm_client.get(f"/projects/groups/{sample_group.slug}/") | |
| 177 | + assert response.status_code == 403 | |
| 178 | + | |
| 179 | + def test_detail_denied_for_anon(self, client, sample_group): | |
| 180 | + response = client.get(f"/projects/groups/{sample_group.slug}/") | |
| 181 | + assert response.status_code == 302 | |
| 182 | + | |
| 183 | + def test_detail_shows_member_projects(self, admin_client, admin_user, org, sample_group): | |
| 184 | + Project.objects.create(name="Fossil Source", organization=org, group=sample_group, created_by=admin_user) | |
| 185 | + response = admin_client.get(f"/projects/groups/{sample_group.slug}/") | |
| 186 | + assert "Fossil Source" in response.content.decode() | |
| 187 | + | |
| 188 | + def test_detail_404_for_deleted_group(self, admin_client, admin_user, sample_group): | |
| 189 | + sample_group.soft_delete(user=admin_user) | |
| 190 | + response = admin_client.get(f"/projects/groups/{sample_group.slug}/") | |
| 191 | + assert response.status_code == 404 | |
| 192 | + | |
| 193 | + | |
| 194 | +# --- View Tests: Group Edit --- | |
| 195 | + | |
| 196 | + | |
| 197 | +@pytest.mark.django_db | |
| 198 | +class TestGroupEditView: | |
| 199 | + def test_edit_get_allowed_for_superuser(self, admin_client, sample_group): | |
| 200 | + response = admin_client.get(f"/projects/groups/{sample_group.slug}/edit/") | |
| 201 | + assert response.status_code == 200 | |
| 202 | + | |
| 203 | + def test_edit_get_allowed_for_editor(self, editor_client, sample_group): | |
| 204 | + response = editor_client.get(f"/projects/groups/{sample_group.slug}/edit/") | |
| 205 | + assert response.status_code == 200 | |
| 206 | + | |
| 207 | + def test_edit_denied_for_viewer(self, viewer_client, sample_group): | |
| 208 | + response = viewer_client.get(f"/projects/groups/{sample_group.slug}/edit/") | |
| 209 | + assert response.status_code == 403 | |
| 210 | + | |
| 211 | + def test_edit_denied_for_no_perm(self, no_perm_client, sample_group): | |
| 212 | + response = no_perm_client.get(f"/projects/groups/{sample_group.slug}/edit/") | |
| 213 | + assert response.status_code == 403 | |
| 214 | + | |
| 215 | + def test_edit_saves_changes(self, admin_client, admin_user, sample_group): | |
| 216 | + response = admin_client.post( | |
| 217 | + f"/projects/groups/{sample_group.slug}/edit/", | |
| 218 | + {"name": "Fossil SCM Updated", "description": "Updated desc"}, | |
| 219 | + ) | |
| 220 | + assert response.status_code == 302 | |
| 221 | + sample_group.refresh_from_db() | |
| 222 | + assert sample_group.name == "Fossil SCM Updated" | |
| 223 | + assert sample_group.description == "Updated desc" | |
| 224 | + assert sample_group.updated_by == admin_user | |
| 225 | + | |
| 226 | + | |
| 227 | +# --- View Tests: Group Delete --- | |
| 228 | + | |
| 229 | + | |
| 230 | +@pytest.mark.django_db | |
| 231 | +class TestGroupDeleteView: | |
| 232 | + def test_delete_get_shows_confirmation(self, admin_client, sample_group): | |
| 233 | + response = admin_client.get(f"/projects/groups/{sample_group.slug}/delete/") | |
| 234 | + assert response.status_code == 200 | |
| 235 | + assert "Fossil SCM" in response.content.decode() | |
| 236 | + assert "Delete" in response.content.decode() | |
| 237 | + | |
| 238 | + def test_delete_denied_for_viewer(self, viewer_client, sample_group): | |
| 239 | + response = viewer_client.post(f"/projects/groups/{sample_group.slug}/delete/") | |
| 240 | + assert response.status_code == 403 | |
| 241 | + | |
| 242 | + def test_delete_denied_for_no_perm(self, no_perm_client, sample_group): | |
| 243 | + response = no_perm_client.post(f"/projects/groups/{sample_group.slug}/delete/") | |
| 244 | + assert response.status_code == 403 | |
| 245 | + | |
| 246 | + def test_delete_soft_deletes_group(self, admin_client, admin_user, sample_group): | |
| 247 | + response = admin_client.post(f"/projects/groups/{sample_group.slug}/delete/") | |
| 248 | + assert response.status_code == 302 | |
| 249 | + sample_group.refresh_from_db() | |
| 250 | + assert sample_group.is_deleted | |
| 251 | + | |
| 252 | + def test_delete_unlinks_projects(self, admin_client, admin_user, org, sample_group): | |
| 253 | + project = Project.objects.create(name="Linked", organization=org, group=sample_group, created_by=admin_user) | |
| 254 | + admin_client.post(f"/projects/groups/{sample_group.slug}/delete/") | |
| 255 | + project.refresh_from_db() | |
| 256 | + assert project.group is None | |
| 257 | + assert not project.is_deleted # Project survives | |
| 258 | + | |
| 259 | + def test_delete_htmx_redirect(self, admin_client, sample_group): | |
| 260 | + response = admin_client.post(f"/projects/groups/{sample_group.slug}/delete/", HTTP_HX_REQUEST="true") | |
| 261 | + assert response.status_code == 200 | |
| 262 | + assert response["HX-Redirect"] == "/projects/groups/" | |
| 263 | + | |
| 264 | + | |
| 265 | +# --- Form Tests --- | |
| 266 | + | |
| 267 | + | |
| 268 | +@pytest.mark.django_db | |
| 269 | +class TestProjectGroupForm: | |
| 270 | + def test_form_valid(self): | |
| 271 | + from projects.forms import ProjectGroupForm | |
| 272 | + | |
| 273 | + form = ProjectGroupForm(data={"name": "Test Group", "description": "A group"}) | |
| 274 | + assert form.is_valid() | |
| 275 | + | |
| 276 | + def test_form_valid_without_description(self): | |
| 277 | + from projects.forms import ProjectGroupForm | |
| 278 | + | |
| 279 | + form = ProjectGroupForm(data={"name": "Test Group", "description": ""}) | |
| 280 | + assert form.is_valid() | |
| 281 | + | |
| 282 | + def test_form_invalid_without_name(self): | |
| 283 | + from projects.forms import ProjectGroupForm | |
| 284 | + | |
| 285 | + form = ProjectGroupForm(data={"name": "", "description": "No name"}) | |
| 286 | + assert not form.is_valid() | |
| 287 | + assert "name" in form.errors | |
| 288 | + | |
| 289 | + | |
| 290 | +# --- ProjectForm group field --- | |
| 291 | + | |
| 292 | + | |
| 293 | +@pytest.mark.django_db | |
| 294 | +class TestProjectFormGroupField: | |
| 295 | + def test_project_form_includes_group(self, sample_group): | |
| 296 | + from projects.forms import ProjectForm | |
| 297 | + | |
| 298 | + form = ProjectForm(data={"name": "Test", "visibility": "private", "group": sample_group.pk}) | |
| 299 | + assert form.is_valid() | |
| 300 | + project_data = form.cleaned_data | |
| 301 | + assert project_data["group"] == sample_group | |
| 302 | + | |
| 303 | + def test_project_form_group_optional(self): | |
| 304 | + from projects.forms import ProjectForm | |
| 305 | + | |
| 306 | + form = ProjectForm(data={"name": "No Group", "visibility": "private"}) | |
| 307 | + assert form.is_valid() | |
| 308 | + assert form.cleaned_data["group"] is None | |
| 309 | + | |
| 310 | + def test_project_create_with_group(self, admin_client, org, sample_group): | |
| 311 | + response = admin_client.post( | |
| 312 | + "/projects/create/", | |
| 313 | + {"name": "Grouped Via Form", "visibility": "private", "group": sample_group.pk}, | |
| 314 | + ) | |
| 315 | + assert response.status_code == 302 | |
| 316 | + project = Project.objects.get(name="Grouped Via Form") | |
| 317 | + assert project.group == sample_group | |
| 318 | + | |
| 319 | + | |
| 320 | +# --- Context Processor Tests --- | |
| 321 | + | |
| 322 | + | |
| 323 | +@pytest.mark.django_db | |
| 324 | +class TestSidebarContext: | |
| 325 | + def test_grouped_projects_in_context(self, admin_client, admin_user, org, sample_group): | |
| 326 | + Project.objects.create(name="Grouped P", organization=org, group=sample_group, created_by=admin_user) | |
| 327 | + Project.objects.create(name="Ungrouped P", organization=org, created_by=admin_user) | |
| 328 | + response = admin_client.get("/dashboard/") | |
| 329 | + assert response.status_code == 200 | |
| 330 | + context = response.context | |
| 331 | + assert "sidebar_grouped" in context | |
| 332 | + assert "sidebar_ungrouped" in context | |
| 333 | + assert len(context["sidebar_grouped"]) == 1 | |
| 334 | + assert context["sidebar_grouped"][0]["group"].name == "Fossil SCM" | |
| 335 | + ungrouped_names = [p.name for p in context["sidebar_ungrouped"]] | |
| 336 | + assert "Ungrouped P" in ungrouped_names | |
| 337 | + | |
| 338 | + def test_empty_group_not_in_sidebar(self, admin_client, sample_group): | |
| 339 | + """A group with no projects should not appear in sidebar_grouped.""" | |
| 340 | + response = admin_client.get("/dashboard/") | |
| 341 | + context = response.context | |
| 342 | + assert len(context["sidebar_grouped"]) == 0 | |
| 343 | + | |
| 344 | + def test_unauthenticated_gets_empty_context(self, client): | |
| 345 | + response = client.get("/dashboard/") | |
| 346 | + # Redirects to login, but if we could check context it would be empty | |
| 347 | + assert response.status_code == 302 |
| --- a/tests/test_project_groups.py | |
| +++ b/tests/test_project_groups.py | |
| @@ -0,0 +1,347 @@ | |
| --- a/tests/test_project_groups.py | |
| +++ b/tests/test_project_groups.py | |
| @@ -0,0 +1,347 @@ | |
| 1 | """Tests for Project Groups: model, views, sidebar context, and permissions.""" |
| 2 | |
| 3 | import pytest |
| 4 | from django.contrib.auth.models import Group, Permission |
| 5 | from django.test import Client |
| 6 | |
| 7 | from projects.models import Project, ProjectGroup |
| 8 | |
| 9 | |
| 10 | @pytest.fixture |
| 11 | def sample_group(db, admin_user): |
| 12 | return ProjectGroup.objects.create(name="Fossil SCM", description="The Fossil repos", created_by=admin_user) |
| 13 | |
| 14 | |
| 15 | @pytest.fixture |
| 16 | def editor_user(db): |
| 17 | """User with full project and projectgroup permissions (add/change/delete/view) but not superuser.""" |
| 18 | user = __import__("django.contrib.auth.models", fromlist=["User"]).User.objects.create_user( |
| 19 | username="editor", email="[email protected]", password="testpass123" |
| 20 | ) |
| 21 | group, _ = Group.objects.get_or_create(name="Editors") |
| 22 | perms = Permission.objects.filter( |
| 23 | content_type__app_label="projects", |
| 24 | ) |
| 25 | group.permissions.set(perms) |
| 26 | user.groups.add(group) |
| 27 | return user |
| 28 | |
| 29 | |
| 30 | @pytest.fixture |
| 31 | def editor_client(editor_user): |
| 32 | client = Client() |
| 33 | client.login(username="editor", password="testpass123") |
| 34 | return client |
| 35 | |
| 36 | |
| 37 | # --- Model Tests --- |
| 38 | |
| 39 | |
| 40 | @pytest.mark.django_db |
| 41 | class TestProjectGroupModel: |
| 42 | def test_create_group(self, admin_user): |
| 43 | group = ProjectGroup.objects.create(name="Test Group", created_by=admin_user) |
| 44 | assert group.slug == "test-group" |
| 45 | assert str(group) == "Test Group" |
| 46 | assert group.guid is not None |
| 47 | |
| 48 | def test_slug_auto_generated(self, admin_user): |
| 49 | group = ProjectGroup.objects.create(name="My Cool Group", created_by=admin_user) |
| 50 | assert group.slug == "my-cool-group" |
| 51 | |
| 52 | def test_slug_uniqueness(self, admin_user): |
| 53 | ProjectGroup.objects.create(name="Dupes", created_by=admin_user) |
| 54 | g2 = ProjectGroup.objects.create(name="Dupes", created_by=admin_user) |
| 55 | assert g2.slug == "dupes-1" |
| 56 | |
| 57 | def test_soft_delete(self, admin_user): |
| 58 | group = ProjectGroup.objects.create(name="Deletable", created_by=admin_user) |
| 59 | group.soft_delete(user=admin_user) |
| 60 | assert group.is_deleted |
| 61 | assert ProjectGroup.objects.filter(name="Deletable").count() == 0 |
| 62 | assert ProjectGroup.all_objects.filter(name="Deletable").count() == 1 |
| 63 | |
| 64 | def test_project_group_fk(self, admin_user, org, sample_group): |
| 65 | project = Project.objects.create(name="Grouped Project", organization=org, group=sample_group, created_by=admin_user) |
| 66 | assert project.group == sample_group |
| 67 | assert project in sample_group.projects.all() |
| 68 | |
| 69 | def test_project_group_nullable(self, admin_user, org): |
| 70 | project = Project.objects.create(name="Ungrouped", organization=org, created_by=admin_user) |
| 71 | assert project.group is None |
| 72 | |
| 73 | def test_group_deletion_sets_null(self, admin_user, org, sample_group): |
| 74 | project = Project.objects.create(name="Will Unlink", organization=org, group=sample_group, created_by=admin_user) |
| 75 | sample_group.delete() |
| 76 | project.refresh_from_db() |
| 77 | assert project.group is None |
| 78 | |
| 79 | |
| 80 | # --- View Tests: Group List --- |
| 81 | |
| 82 | |
| 83 | @pytest.mark.django_db |
| 84 | class TestGroupListView: |
| 85 | def test_list_allowed_for_superuser(self, admin_client, sample_group): |
| 86 | response = admin_client.get("/projects/groups/") |
| 87 | assert response.status_code == 200 |
| 88 | assert "Fossil SCM" in response.content.decode() |
| 89 | |
| 90 | def test_list_allowed_for_viewer(self, viewer_client, sample_group): |
| 91 | response = viewer_client.get("/projects/groups/") |
| 92 | assert response.status_code == 200 |
| 93 | |
| 94 | def test_list_denied_for_no_perm(self, no_perm_client): |
| 95 | response = no_perm_client.get("/projects/groups/") |
| 96 | assert response.status_code == 403 |
| 97 | |
| 98 | def test_list_denied_for_anon(self, client): |
| 99 | response = client.get("/projects/groups/") |
| 100 | assert response.status_code == 302 |
| 101 | |
| 102 | def test_list_shows_project_count(self, admin_client, admin_user, org, sample_group): |
| 103 | Project.objects.create(name="P1", organization=org, group=sample_group, created_by=admin_user) |
| 104 | Project.objects.create(name="P2", organization=org, group=sample_group, created_by=admin_user) |
| 105 | response = admin_client.get("/projects/groups/") |
| 106 | content = response.content.decode() |
| 107 | assert "Fossil SCM" in content |
| 108 | |
| 109 | def test_list_htmx_returns_partial(self, admin_client, sample_group): |
| 110 | response = admin_client.get("/projects/groups/", HTTP_HX_REQUEST="true") |
| 111 | assert response.status_code == 200 |
| 112 | content = response.content.decode() |
| 113 | assert "Fossil SCM" in content |
| 114 | # Partial should not have full page structure |
| 115 | assert "<!DOCTYPE html>" not in content |
| 116 | |
| 117 | |
| 118 | # --- View Tests: Group Create --- |
| 119 | |
| 120 | |
| 121 | @pytest.mark.django_db |
| 122 | class TestGroupCreateView: |
| 123 | def test_create_get_allowed_for_superuser(self, admin_client): |
| 124 | response = admin_client.get("/projects/groups/create/") |
| 125 | assert response.status_code == 200 |
| 126 | |
| 127 | def test_create_get_allowed_for_editor(self, editor_client): |
| 128 | response = editor_client.get("/projects/groups/create/") |
| 129 | assert response.status_code == 200 |
| 130 | |
| 131 | def test_create_denied_for_viewer(self, viewer_client): |
| 132 | response = viewer_client.get("/projects/groups/create/") |
| 133 | assert response.status_code == 403 |
| 134 | |
| 135 | def test_create_denied_for_no_perm(self, no_perm_client): |
| 136 | response = no_perm_client.get("/projects/groups/create/") |
| 137 | assert response.status_code == 403 |
| 138 | |
| 139 | def test_create_denied_for_anon(self, client): |
| 140 | response = client.get("/projects/groups/create/") |
| 141 | assert response.status_code == 302 |
| 142 | |
| 143 | def test_create_saves_group(self, admin_client, admin_user): |
| 144 | response = admin_client.post("/projects/groups/create/", {"name": "New Group", "description": "Desc"}) |
| 145 | assert response.status_code == 302 |
| 146 | group = ProjectGroup.objects.get(name="New Group") |
| 147 | assert group.description == "Desc" |
| 148 | assert group.created_by == admin_user |
| 149 | |
| 150 | def test_create_redirects_to_detail(self, admin_client): |
| 151 | response = admin_client.post("/projects/groups/create/", {"name": "Redirect Test"}) |
| 152 | assert response.status_code == 302 |
| 153 | group = ProjectGroup.objects.get(name="Redirect Test") |
| 154 | assert response.url == f"/projects/groups/{group.slug}/" |
| 155 | |
| 156 | def test_create_requires_name(self, admin_client): |
| 157 | response = admin_client.post("/projects/groups/create/", {"name": "", "description": "No name"}) |
| 158 | assert response.status_code == 200 # Re-renders form |
| 159 | |
| 160 | |
| 161 | # --- View Tests: Group Detail --- |
| 162 | |
| 163 | |
| 164 | @pytest.mark.django_db |
| 165 | class TestGroupDetailView: |
| 166 | def test_detail_allowed_for_superuser(self, admin_client, sample_group): |
| 167 | response = admin_client.get(f"/projects/groups/{sample_group.slug}/") |
| 168 | assert response.status_code == 200 |
| 169 | assert "Fossil SCM" in response.content.decode() |
| 170 | |
| 171 | def test_detail_allowed_for_viewer(self, viewer_client, sample_group): |
| 172 | response = viewer_client.get(f"/projects/groups/{sample_group.slug}/") |
| 173 | assert response.status_code == 200 |
| 174 | |
| 175 | def test_detail_denied_for_no_perm(self, no_perm_client, sample_group): |
| 176 | response = no_perm_client.get(f"/projects/groups/{sample_group.slug}/") |
| 177 | assert response.status_code == 403 |
| 178 | |
| 179 | def test_detail_denied_for_anon(self, client, sample_group): |
| 180 | response = client.get(f"/projects/groups/{sample_group.slug}/") |
| 181 | assert response.status_code == 302 |
| 182 | |
| 183 | def test_detail_shows_member_projects(self, admin_client, admin_user, org, sample_group): |
| 184 | Project.objects.create(name="Fossil Source", organization=org, group=sample_group, created_by=admin_user) |
| 185 | response = admin_client.get(f"/projects/groups/{sample_group.slug}/") |
| 186 | assert "Fossil Source" in response.content.decode() |
| 187 | |
| 188 | def test_detail_404_for_deleted_group(self, admin_client, admin_user, sample_group): |
| 189 | sample_group.soft_delete(user=admin_user) |
| 190 | response = admin_client.get(f"/projects/groups/{sample_group.slug}/") |
| 191 | assert response.status_code == 404 |
| 192 | |
| 193 | |
| 194 | # --- View Tests: Group Edit --- |
| 195 | |
| 196 | |
| 197 | @pytest.mark.django_db |
| 198 | class TestGroupEditView: |
| 199 | def test_edit_get_allowed_for_superuser(self, admin_client, sample_group): |
| 200 | response = admin_client.get(f"/projects/groups/{sample_group.slug}/edit/") |
| 201 | assert response.status_code == 200 |
| 202 | |
| 203 | def test_edit_get_allowed_for_editor(self, editor_client, sample_group): |
| 204 | response = editor_client.get(f"/projects/groups/{sample_group.slug}/edit/") |
| 205 | assert response.status_code == 200 |
| 206 | |
| 207 | def test_edit_denied_for_viewer(self, viewer_client, sample_group): |
| 208 | response = viewer_client.get(f"/projects/groups/{sample_group.slug}/edit/") |
| 209 | assert response.status_code == 403 |
| 210 | |
| 211 | def test_edit_denied_for_no_perm(self, no_perm_client, sample_group): |
| 212 | response = no_perm_client.get(f"/projects/groups/{sample_group.slug}/edit/") |
| 213 | assert response.status_code == 403 |
| 214 | |
| 215 | def test_edit_saves_changes(self, admin_client, admin_user, sample_group): |
| 216 | response = admin_client.post( |
| 217 | f"/projects/groups/{sample_group.slug}/edit/", |
| 218 | {"name": "Fossil SCM Updated", "description": "Updated desc"}, |
| 219 | ) |
| 220 | assert response.status_code == 302 |
| 221 | sample_group.refresh_from_db() |
| 222 | assert sample_group.name == "Fossil SCM Updated" |
| 223 | assert sample_group.description == "Updated desc" |
| 224 | assert sample_group.updated_by == admin_user |
| 225 | |
| 226 | |
| 227 | # --- View Tests: Group Delete --- |
| 228 | |
| 229 | |
| 230 | @pytest.mark.django_db |
| 231 | class TestGroupDeleteView: |
| 232 | def test_delete_get_shows_confirmation(self, admin_client, sample_group): |
| 233 | response = admin_client.get(f"/projects/groups/{sample_group.slug}/delete/") |
| 234 | assert response.status_code == 200 |
| 235 | assert "Fossil SCM" in response.content.decode() |
| 236 | assert "Delete" in response.content.decode() |
| 237 | |
| 238 | def test_delete_denied_for_viewer(self, viewer_client, sample_group): |
| 239 | response = viewer_client.post(f"/projects/groups/{sample_group.slug}/delete/") |
| 240 | assert response.status_code == 403 |
| 241 | |
| 242 | def test_delete_denied_for_no_perm(self, no_perm_client, sample_group): |
| 243 | response = no_perm_client.post(f"/projects/groups/{sample_group.slug}/delete/") |
| 244 | assert response.status_code == 403 |
| 245 | |
| 246 | def test_delete_soft_deletes_group(self, admin_client, admin_user, sample_group): |
| 247 | response = admin_client.post(f"/projects/groups/{sample_group.slug}/delete/") |
| 248 | assert response.status_code == 302 |
| 249 | sample_group.refresh_from_db() |
| 250 | assert sample_group.is_deleted |
| 251 | |
| 252 | def test_delete_unlinks_projects(self, admin_client, admin_user, org, sample_group): |
| 253 | project = Project.objects.create(name="Linked", organization=org, group=sample_group, created_by=admin_user) |
| 254 | admin_client.post(f"/projects/groups/{sample_group.slug}/delete/") |
| 255 | project.refresh_from_db() |
| 256 | assert project.group is None |
| 257 | assert not project.is_deleted # Project survives |
| 258 | |
| 259 | def test_delete_htmx_redirect(self, admin_client, sample_group): |
| 260 | response = admin_client.post(f"/projects/groups/{sample_group.slug}/delete/", HTTP_HX_REQUEST="true") |
| 261 | assert response.status_code == 200 |
| 262 | assert response["HX-Redirect"] == "/projects/groups/" |
| 263 | |
| 264 | |
| 265 | # --- Form Tests --- |
| 266 | |
| 267 | |
| 268 | @pytest.mark.django_db |
| 269 | class TestProjectGroupForm: |
| 270 | def test_form_valid(self): |
| 271 | from projects.forms import ProjectGroupForm |
| 272 | |
| 273 | form = ProjectGroupForm(data={"name": "Test Group", "description": "A group"}) |
| 274 | assert form.is_valid() |
| 275 | |
| 276 | def test_form_valid_without_description(self): |
| 277 | from projects.forms import ProjectGroupForm |
| 278 | |
| 279 | form = ProjectGroupForm(data={"name": "Test Group", "description": ""}) |
| 280 | assert form.is_valid() |
| 281 | |
| 282 | def test_form_invalid_without_name(self): |
| 283 | from projects.forms import ProjectGroupForm |
| 284 | |
| 285 | form = ProjectGroupForm(data={"name": "", "description": "No name"}) |
| 286 | assert not form.is_valid() |
| 287 | assert "name" in form.errors |
| 288 | |
| 289 | |
| 290 | # --- ProjectForm group field --- |
| 291 | |
| 292 | |
| 293 | @pytest.mark.django_db |
| 294 | class TestProjectFormGroupField: |
| 295 | def test_project_form_includes_group(self, sample_group): |
| 296 | from projects.forms import ProjectForm |
| 297 | |
| 298 | form = ProjectForm(data={"name": "Test", "visibility": "private", "group": sample_group.pk}) |
| 299 | assert form.is_valid() |
| 300 | project_data = form.cleaned_data |
| 301 | assert project_data["group"] == sample_group |
| 302 | |
| 303 | def test_project_form_group_optional(self): |
| 304 | from projects.forms import ProjectForm |
| 305 | |
| 306 | form = ProjectForm(data={"name": "No Group", "visibility": "private"}) |
| 307 | assert form.is_valid() |
| 308 | assert form.cleaned_data["group"] is None |
| 309 | |
| 310 | def test_project_create_with_group(self, admin_client, org, sample_group): |
| 311 | response = admin_client.post( |
| 312 | "/projects/create/", |
| 313 | {"name": "Grouped Via Form", "visibility": "private", "group": sample_group.pk}, |
| 314 | ) |
| 315 | assert response.status_code == 302 |
| 316 | project = Project.objects.get(name="Grouped Via Form") |
| 317 | assert project.group == sample_group |
| 318 | |
| 319 | |
| 320 | # --- Context Processor Tests --- |
| 321 | |
| 322 | |
| 323 | @pytest.mark.django_db |
| 324 | class TestSidebarContext: |
| 325 | def test_grouped_projects_in_context(self, admin_client, admin_user, org, sample_group): |
| 326 | Project.objects.create(name="Grouped P", organization=org, group=sample_group, created_by=admin_user) |
| 327 | Project.objects.create(name="Ungrouped P", organization=org, created_by=admin_user) |
| 328 | response = admin_client.get("/dashboard/") |
| 329 | assert response.status_code == 200 |
| 330 | context = response.context |
| 331 | assert "sidebar_grouped" in context |
| 332 | assert "sidebar_ungrouped" in context |
| 333 | assert len(context["sidebar_grouped"]) == 1 |
| 334 | assert context["sidebar_grouped"][0]["group"].name == "Fossil SCM" |
| 335 | ungrouped_names = [p.name for p in context["sidebar_ungrouped"]] |
| 336 | assert "Ungrouped P" in ungrouped_names |
| 337 | |
| 338 | def test_empty_group_not_in_sidebar(self, admin_client, sample_group): |
| 339 | """A group with no projects should not appear in sidebar_grouped.""" |
| 340 | response = admin_client.get("/dashboard/") |
| 341 | context = response.context |
| 342 | assert len(context["sidebar_grouped"]) == 0 |
| 343 | |
| 344 | def test_unauthenticated_gets_empty_context(self, client): |
| 345 | response = client.get("/dashboard/") |
| 346 | # Redirects to login, but if we could check context it would be empty |
| 347 | assert response.status_code == 302 |
| --- a/tests/test_roles.py | ||
| +++ b/tests/test_roles.py | ||
| @@ -0,0 +1,360 @@ | ||
| 1 | +import pytest | |
| 2 | +from django.contrib.auth.models import Group, Permission, User | |
| 3 | +from django.test import Client | |
| 4 | +from django.urls import reverse | |
| 5 | + | |
| 6 | +from organization.models import OrganizationMember, OrgRole | |
| 7 | + | |
| 8 | + | |
| 9 | +@pytest.fixture | |
| 10 | +def roles(db): | |
| 11 | + """Seed default roles via management command.""" | |
| 12 | + from django.core.management import call_command | |
| 13 | + | |
| 14 | + call_command("seed_roles") | |
| 15 | + return OrgRole.objects.all() | |
| 16 | + | |
| 17 | + | |
| 18 | +@pytest.fixture | |
| 19 | +def admin_role(roles): | |
| 20 | + return OrgRole.objects.get(slug="admin") | |
| 21 | + | |
| 22 | + | |
| 23 | +@pytest.fixture | |
| 24 | +def viewer_role(roles): | |
| 25 | + return OrgRole.objects.get(slug="viewer") | |
| 26 | + | |
| 27 | + | |
| 28 | +@pytest.fixture | |
| 29 | +def developer_role(roles): | |
| 30 | + return OrgRole.objects.get(slug="developer") | |
| 31 | + | |
| 32 | + | |
| 33 | +@pytest.fixture | |
| 34 | +def manager_role(roles): | |
| 35 | + return OrgRole.objects.get(slug="manager") | |
| 36 | + | |
| 37 | + | |
| 38 | +@pytest.fixture | |
| 39 | +def target_user(db, org, admin_user): | |
| 40 | + user = User.objects.create_user( | |
| 41 | + username="targetuser", email="[email protected]", password="testpass123", first_name="Target", last_name="User" | |
| 42 | + ) | |
| 43 | + OrganizationMember.objects.create(member=user, organization=org, created_by=admin_user) | |
| 44 | + return user | |
| 45 | + | |
| 46 | + | |
| 47 | +@pytest.fixture | |
| 48 | +def org_admin_user(db, org): | |
| 49 | + """Non-superuser with ORGANIZATION_CHANGE permission.""" | |
| 50 | + user = User.objects.create_user(username="orgadmin", email="[email protected]", password="testpass123") | |
| 51 | + group, _ = Group.objects.get_or_create(name="OrgAdmins") | |
| 52 | + change_perm = Permission.objects.get(content_type__app_label="organization", codename="change_organization") | |
| 53 | + view_perm = Permission.objects.get(content_type__app_label="organization", codename="view_organization") | |
| 54 | + view_member_perm = Permission.objects.get(content_type__app_label="organization", codename="view_organizationmember") | |
| 55 | + group.permissions.add(change_perm, view_perm, view_member_perm) | |
| 56 | + user.groups.add(group) | |
| 57 | + OrganizationMember.objects.create(member=user, organization=org) | |
| 58 | + return user | |
| 59 | + | |
| 60 | + | |
| 61 | +@pytest.fixture | |
| 62 | +def org_admin_client(org_admin_user): | |
| 63 | + c = Client() | |
| 64 | + c.login(username="orgadmin", password="testpass123") | |
| 65 | + return c | |
| 66 | + | |
| 67 | + | |
| 68 | +# --- OrgRole model --- | |
| 69 | + | |
| 70 | + | |
| 71 | +@pytest.mark.django_db | |
| 72 | +class TestOrgRoleModel: | |
| 73 | + def test_seed_creates_four_roles(self, roles): | |
| 74 | + assert OrgRole.objects.count() == 4 | |
| 75 | + | |
| 76 | + def test_seed_idempotent(self, roles): | |
| 77 | + from django.core.management import call_command | |
| 78 | + | |
| 79 | + call_command("seed_roles") | |
| 80 | + assert OrgRole.objects.count() == 4 | |
| 81 | + | |
| 82 | + def test_admin_role_has_all_app_permissions(self, admin_role): | |
| 83 | + app_perms = Permission.objects.filter(content_type__app_label__in=["organization", "projects", "pages", "fossil"]).count() | |
| 84 | + assert admin_role.permissions.count() == app_perms | |
| 85 | + | |
| 86 | + def test_viewer_role_is_default(self, viewer_role): | |
| 87 | + assert viewer_role.is_default is True | |
| 88 | + | |
| 89 | + def test_admin_role_not_default(self, admin_role): | |
| 90 | + assert admin_role.is_default is False | |
| 91 | + | |
| 92 | + def test_viewer_has_only_view_permissions(self, viewer_role): | |
| 93 | + for perm in viewer_role.permissions.all(): | |
| 94 | + assert perm.codename.startswith("view_"), f"Viewer role should only have view_ permissions, got {perm.codename}" | |
| 95 | + | |
| 96 | + def test_developer_has_add_page(self, developer_role): | |
| 97 | + assert developer_role.permissions.filter(codename="add_page").exists() | |
| 98 | + | |
| 99 | + def test_developer_no_delete_project(self, developer_role): | |
| 100 | + assert not developer_role.permissions.filter(codename="delete_project").exists() | |
| 101 | + | |
| 102 | + def test_manager_has_change_organization(self, manager_role): | |
| 103 | + assert manager_role.permissions.filter(codename="change_organization").exists() | |
| 104 | + | |
| 105 | + | |
| 106 | +# --- apply_to_user --- | |
| 107 | + | |
| 108 | + | |
| 109 | +@pytest.mark.django_db | |
| 110 | +class TestApplyToUser: | |
| 111 | + def test_apply_creates_role_group(self, viewer_role, target_user): | |
| 112 | + viewer_role.apply_to_user(target_user) | |
| 113 | + assert target_user.groups.filter(name="role_viewer").exists() | |
| 114 | + | |
| 115 | + def test_apply_sets_permissions(self, viewer_role, target_user): | |
| 116 | + viewer_role.apply_to_user(target_user) | |
| 117 | + assert target_user.has_perm("organization.view_organization") | |
| 118 | + | |
| 119 | + def test_apply_replaces_old_role(self, viewer_role, admin_role, target_user): | |
| 120 | + viewer_role.apply_to_user(target_user) | |
| 121 | + admin_role.apply_to_user(target_user) | |
| 122 | + # Should only be in admin role group now | |
| 123 | + role_groups = target_user.groups.filter(name__startswith="role_") | |
| 124 | + assert role_groups.count() == 1 | |
| 125 | + assert role_groups.first().name == "role_admin" | |
| 126 | + | |
| 127 | + def test_remove_role_groups(self, viewer_role, target_user): | |
| 128 | + viewer_role.apply_to_user(target_user) | |
| 129 | + assert target_user.groups.filter(name__startswith="role_").count() == 1 | |
| 130 | + OrgRole.remove_role_groups(target_user) | |
| 131 | + assert target_user.groups.filter(name__startswith="role_").count() == 0 | |
| 132 | + | |
| 133 | + | |
| 134 | +# --- role_list view --- | |
| 135 | + | |
| 136 | + | |
| 137 | +@pytest.mark.django_db | |
| 138 | +class TestRoleListView: | |
| 139 | + def test_list_empty(self, admin_client, org): | |
| 140 | + response = admin_client.get(reverse("organization:role_list")) | |
| 141 | + assert response.status_code == 200 | |
| 142 | + assert "No roles defined" in response.content.decode() | |
| 143 | + | |
| 144 | + def test_list_with_roles(self, admin_client, org, roles): | |
| 145 | + response = admin_client.get(reverse("organization:role_list")) | |
| 146 | + assert response.status_code == 200 | |
| 147 | + content = response.content.decode() | |
| 148 | + assert "Admin" in content | |
| 149 | + assert "Manager" in content | |
| 150 | + assert "Developer" in content | |
| 151 | + assert "Viewer" in content | |
| 152 | + | |
| 153 | + def test_list_denied_for_no_perm(self, no_perm_client, org): | |
| 154 | + response = no_perm_client.get(reverse("organization:role_list")) | |
| 155 | + assert response.status_code == 403 | |
| 156 | + | |
| 157 | + def test_list_allowed_for_viewer(self, viewer_client, org, roles): | |
| 158 | + response = viewer_client.get(reverse("organization:role_list")) | |
| 159 | + assert response.status_code == 200 | |
| 160 | + | |
| 161 | + def test_list_denied_for_anon(self, client, org): | |
| 162 | + response = client.get(reverse("organization:role_list")) | |
| 163 | + assert response.status_code == 302 # redirect to login | |
| 164 | + | |
| 165 | + | |
| 166 | +# --- role_detail view --- | |
| 167 | + | |
| 168 | + | |
| 169 | +@pytest.mark.django_db | |
| 170 | +class TestRoleDetailView: | |
| 171 | + def test_detail_shows_role_info(self, admin_client, org, admin_role): | |
| 172 | + response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "admin"})) | |
| 173 | + assert response.status_code == 200 | |
| 174 | + content = response.content.decode() | |
| 175 | + assert "Admin" in content | |
| 176 | + assert "Full access" in content | |
| 177 | + | |
| 178 | + def test_detail_shows_permissions(self, admin_client, org, viewer_role): | |
| 179 | + response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"})) | |
| 180 | + assert response.status_code == 200 | |
| 181 | + content = response.content.decode() | |
| 182 | + assert "view_organization" in content | |
| 183 | + | |
| 184 | + def test_detail_shows_members(self, admin_client, org, viewer_role, target_user): | |
| 185 | + membership = OrganizationMember.objects.get(member=target_user, organization=org) | |
| 186 | + membership.role = viewer_role | |
| 187 | + membership.save() | |
| 188 | + response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"})) | |
| 189 | + assert response.status_code == 200 | |
| 190 | + assert "targetuser" in response.content.decode() | |
| 191 | + | |
| 192 | + def test_detail_denied_for_no_perm(self, no_perm_client, org, viewer_role): | |
| 193 | + response = no_perm_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"})) | |
| 194 | + assert response.status_code == 403 | |
| 195 | + | |
| 196 | + def test_detail_404_for_missing_role(self, admin_client, org): | |
| 197 | + response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "nonexistent"})) | |
| 198 | + assert response.status_code == 404 | |
| 199 | + | |
| 200 | + | |
| 201 | +# --- role_initialize view --- | |
| 202 | + | |
| 203 | + | |
| 204 | +@pytest.mark.django_db | |
| 205 | +class TestRoleInitializeView: | |
| 206 | + def test_initialize_creates_roles(self, admin_client, org): | |
| 207 | + assert OrgRole.objects.count() == 0 | |
| 208 | + response = admin_client.post(reverse("organization:role_initialize")) | |
| 209 | + assert response.status_code == 302 | |
| 210 | + assert OrgRole.objects.count() == 4 | |
| 211 | + | |
| 212 | + def test_initialize_denied_for_viewer(self, viewer_client, org): | |
| 213 | + response = viewer_client.post(reverse("organization:role_initialize")) | |
| 214 | + assert response.status_code == 403 | |
| 215 | + | |
| 216 | + def test_initialize_denied_for_no_perm(self, no_perm_client, org): | |
| 217 | + response = no_perm_client.post(reverse("organization:role_initialize")) | |
| 218 | + assert response.status_code == 403 | |
| 219 | + | |
| 220 | + def test_initialize_denied_for_anon(self, client, org): | |
| 221 | + response = client.post(reverse("organization:role_initialize")) | |
| 222 | + assert response.status_code == 302 # redirect to login | |
| 223 | + | |
| 224 | + def test_initialize_allowed_for_org_admin(self, org_admin_client, org): | |
| 225 | + response = org_admin_client.post(reverse("organization:role_initialize")) | |
| 226 | + assert response.status_code == 302 | |
| 227 | + assert OrgRole.objects.count() == 4 | |
| 228 | + | |
| 229 | + | |
| 230 | +# --- user_create with role --- | |
| 231 | + | |
| 232 | + | |
| 233 | +@pytest.mark.django_db | |
| 234 | +class TestUserCreateWithRole: | |
| 235 | + def test_create_user_with_role(self, admin_client, org, viewer_role): | |
| 236 | + response = admin_client.post( | |
| 237 | + reverse("organization:user_create"), | |
| 238 | + { | |
| 239 | + "username": "roleuser", | |
| 240 | + "email": "[email protected]", | |
| 241 | + "first_name": "Role", | |
| 242 | + "last_name": "User", | |
| 243 | + "password1": "Str0ng!Pass99", | |
| 244 | + "password2": "Str0ng!Pass99", | |
| 245 | + "role": viewer_role.pk, | |
| 246 | + }, | |
| 247 | + ) | |
| 248 | + assert response.status_code == 302 | |
| 249 | + user = User.objects.get(username="roleuser") | |
| 250 | + membership = OrganizationMember.objects.get(member=user, organization=org) | |
| 251 | + assert membership.role == viewer_role | |
| 252 | + # Verify role group was applied | |
| 253 | + assert user.groups.filter(name="role_viewer").exists() | |
| 254 | + | |
| 255 | + def test_create_user_without_role(self, admin_client, org, roles): | |
| 256 | + response = admin_client.post( | |
| 257 | + reverse("organization:user_create"), | |
| 258 | + { | |
| 259 | + "username": "noroleuser", | |
| 260 | + "email": "[email protected]", | |
| 261 | + "password1": "Str0ng!Pass99", | |
| 262 | + "password2": "Str0ng!Pass99", | |
| 263 | + }, | |
| 264 | + ) | |
| 265 | + assert response.status_code == 302 | |
| 266 | + user = User.objects.get(username="noroleuser") | |
| 267 | + membership = OrganizationMember.objects.get(member=user, organization=org) | |
| 268 | + assert membership.role is None | |
| 269 | + | |
| 270 | + def test_create_form_has_role_field(self, admin_client, org, roles): | |
| 271 | + response = admin_client.get(reverse("organization:user_create")) | |
| 272 | + assert response.status_code == 200 | |
| 273 | + content = response.content.decode() | |
| 274 | + assert "role" in content.lower() | |
| 275 | + | |
| 276 | + | |
| 277 | +# --- user_edit with role --- | |
| 278 | + | |
| 279 | + | |
| 280 | +@pytest.mark.django_db | |
| 281 | +class TestUserEditWithRole: | |
| 282 | + def test_edit_assigns_role(self, admin_client, org, target_user, viewer_role): | |
| 283 | + response = admin_client.post( | |
| 284 | + reverse("organization:user_edit", kwargs={"username": "targetuser"}), | |
| 285 | + { | |
| 286 | + "email": "[email protected]", | |
| 287 | + "first_name": "Target", | |
| 288 | + "last_name": "User", | |
| 289 | + "is_active": "on", | |
| 290 | + "role": viewer_role.pk, | |
| 291 | + }, | |
| 292 | + ) | |
| 293 | + assert response.status_code == 302 | |
| 294 | + membership = OrganizationMember.objects.get(member=target_user, organization=org) | |
| 295 | + assert membership.role == viewer_role | |
| 296 | + assert target_user.groups.filter(name="role_viewer").exists() | |
| 297 | + | |
| 298 | + def test_edit_changes_role(self, admin_client, org, target_user, viewer_role, admin_role): | |
| 299 | + # First assign viewer role | |
| 300 | + membership = OrganizationMember.objects.get(member=target_user, organization=org) | |
| 301 | + membership.role = viewer_role | |
| 302 | + membership.save() | |
| 303 | + viewer_role.apply_to_user(target_user) | |
| 304 | + | |
| 305 | + # Now change to admin role via edit | |
| 306 | + response = admin_client.post( | |
| 307 | + reverse("organization:user_edit", kwargs={"username": "targetuser"}), | |
| 308 | + { | |
| 309 | + "email": "[email protected]", | |
| 310 | + "first_name": "Target", | |
| 311 | + "last_name": "User", | |
| 312 | + "is_active": "on", | |
| 313 | + "role": admin_role.pk, | |
| 314 | + }, | |
| 315 | + ) | |
| 316 | + assert response.status_code == 302 | |
| 317 | + membership.refresh_from_db() | |
| 318 | + assert membership.role == admin_role | |
| 319 | + # Old role group should be gone, new one should be present | |
| 320 | + assert not target_user.groups.filter(name="role_viewer").exists() | |
| 321 | + assert target_user.groups.filter(name="role_admin").exists() | |
| 322 | + | |
| 323 | + def test_edit_removes_role(self, admin_client, org, target_user, viewer_role): | |
| 324 | + membership = OrganizationMember.objects.get(member=target_user, organization=org) | |
| 325 | + membership.role = viewer_role | |
| 326 | + membership.save() | |
| 327 | + viewer_role.apply_to_user(target_user) | |
| 328 | + | |
| 329 | + # Submit without role | |
| 330 | + response = admin_client.post( | |
| 331 | + reverse("organization:user_edit", kwargs={"username": "targetuser"}), | |
| 332 | + { | |
| 333 | + "email": "[email protected]", | |
| 334 | + "first_name": "Target", | |
| 335 | + "last_name": "User", | |
| 336 | + "is_active": "on", | |
| 337 | + # role intentionally omitted | |
| 338 | + }, | |
| 339 | + ) | |
| 340 | + assert response.status_code == 302 | |
| 341 | + membership.refresh_from_db() | |
| 342 | + assert membership.role is None | |
| 343 | + assert not target_user.groups.filter(name__startswith="role_").exists() | |
| 344 | + | |
| 345 | + def test_edit_form_pre_selects_role(self, admin_client, org, target_user, viewer_role): | |
| 346 | + membership = OrganizationMember.objects.get(member=target_user, organization=org) | |
| 347 | + membership.role = viewer_role | |
| 348 | + membership.save() | |
| 349 | + | |
| 350 | + response = admin_client.get(reverse("organization:user_edit", kwargs={"username": "targetuser"})) | |
| 351 | + assert response.status_code == 200 | |
| 352 | + content = response.content.decode() | |
| 353 | + # The viewer option should be selected | |
| 354 | + assert "selected" in content | |
| 355 | + | |
| 356 | + | |
| 357 | +# --- member_list role column --- | |
| 358 | + | |
| 359 | + | |
| 360 | +@p |
| --- a/tests/test_roles.py | |
| +++ b/tests/test_roles.py | |
| @@ -0,0 +1,360 @@ | |
| --- a/tests/test_roles.py | |
| +++ b/tests/test_roles.py | |
| @@ -0,0 +1,360 @@ | |
| 1 | import pytest |
| 2 | from django.contrib.auth.models import Group, Permission, User |
| 3 | from django.test import Client |
| 4 | from django.urls import reverse |
| 5 | |
| 6 | from organization.models import OrganizationMember, OrgRole |
| 7 | |
| 8 | |
| 9 | @pytest.fixture |
| 10 | def roles(db): |
| 11 | """Seed default roles via management command.""" |
| 12 | from django.core.management import call_command |
| 13 | |
| 14 | call_command("seed_roles") |
| 15 | return OrgRole.objects.all() |
| 16 | |
| 17 | |
| 18 | @pytest.fixture |
| 19 | def admin_role(roles): |
| 20 | return OrgRole.objects.get(slug="admin") |
| 21 | |
| 22 | |
| 23 | @pytest.fixture |
| 24 | def viewer_role(roles): |
| 25 | return OrgRole.objects.get(slug="viewer") |
| 26 | |
| 27 | |
| 28 | @pytest.fixture |
| 29 | def developer_role(roles): |
| 30 | return OrgRole.objects.get(slug="developer") |
| 31 | |
| 32 | |
| 33 | @pytest.fixture |
| 34 | def manager_role(roles): |
| 35 | return OrgRole.objects.get(slug="manager") |
| 36 | |
| 37 | |
| 38 | @pytest.fixture |
| 39 | def target_user(db, org, admin_user): |
| 40 | user = User.objects.create_user( |
| 41 | username="targetuser", email="[email protected]", password="testpass123", first_name="Target", last_name="User" |
| 42 | ) |
| 43 | OrganizationMember.objects.create(member=user, organization=org, created_by=admin_user) |
| 44 | return user |
| 45 | |
| 46 | |
| 47 | @pytest.fixture |
| 48 | def org_admin_user(db, org): |
| 49 | """Non-superuser with ORGANIZATION_CHANGE permission.""" |
| 50 | user = User.objects.create_user(username="orgadmin", email="[email protected]", password="testpass123") |
| 51 | group, _ = Group.objects.get_or_create(name="OrgAdmins") |
| 52 | change_perm = Permission.objects.get(content_type__app_label="organization", codename="change_organization") |
| 53 | view_perm = Permission.objects.get(content_type__app_label="organization", codename="view_organization") |
| 54 | view_member_perm = Permission.objects.get(content_type__app_label="organization", codename="view_organizationmember") |
| 55 | group.permissions.add(change_perm, view_perm, view_member_perm) |
| 56 | user.groups.add(group) |
| 57 | OrganizationMember.objects.create(member=user, organization=org) |
| 58 | return user |
| 59 | |
| 60 | |
| 61 | @pytest.fixture |
| 62 | def org_admin_client(org_admin_user): |
| 63 | c = Client() |
| 64 | c.login(username="orgadmin", password="testpass123") |
| 65 | return c |
| 66 | |
| 67 | |
| 68 | # --- OrgRole model --- |
| 69 | |
| 70 | |
| 71 | @pytest.mark.django_db |
| 72 | class TestOrgRoleModel: |
| 73 | def test_seed_creates_four_roles(self, roles): |
| 74 | assert OrgRole.objects.count() == 4 |
| 75 | |
| 76 | def test_seed_idempotent(self, roles): |
| 77 | from django.core.management import call_command |
| 78 | |
| 79 | call_command("seed_roles") |
| 80 | assert OrgRole.objects.count() == 4 |
| 81 | |
| 82 | def test_admin_role_has_all_app_permissions(self, admin_role): |
| 83 | app_perms = Permission.objects.filter(content_type__app_label__in=["organization", "projects", "pages", "fossil"]).count() |
| 84 | assert admin_role.permissions.count() == app_perms |
| 85 | |
| 86 | def test_viewer_role_is_default(self, viewer_role): |
| 87 | assert viewer_role.is_default is True |
| 88 | |
| 89 | def test_admin_role_not_default(self, admin_role): |
| 90 | assert admin_role.is_default is False |
| 91 | |
| 92 | def test_viewer_has_only_view_permissions(self, viewer_role): |
| 93 | for perm in viewer_role.permissions.all(): |
| 94 | assert perm.codename.startswith("view_"), f"Viewer role should only have view_ permissions, got {perm.codename}" |
| 95 | |
| 96 | def test_developer_has_add_page(self, developer_role): |
| 97 | assert developer_role.permissions.filter(codename="add_page").exists() |
| 98 | |
| 99 | def test_developer_no_delete_project(self, developer_role): |
| 100 | assert not developer_role.permissions.filter(codename="delete_project").exists() |
| 101 | |
| 102 | def test_manager_has_change_organization(self, manager_role): |
| 103 | assert manager_role.permissions.filter(codename="change_organization").exists() |
| 104 | |
| 105 | |
| 106 | # --- apply_to_user --- |
| 107 | |
| 108 | |
| 109 | @pytest.mark.django_db |
| 110 | class TestApplyToUser: |
| 111 | def test_apply_creates_role_group(self, viewer_role, target_user): |
| 112 | viewer_role.apply_to_user(target_user) |
| 113 | assert target_user.groups.filter(name="role_viewer").exists() |
| 114 | |
| 115 | def test_apply_sets_permissions(self, viewer_role, target_user): |
| 116 | viewer_role.apply_to_user(target_user) |
| 117 | assert target_user.has_perm("organization.view_organization") |
| 118 | |
| 119 | def test_apply_replaces_old_role(self, viewer_role, admin_role, target_user): |
| 120 | viewer_role.apply_to_user(target_user) |
| 121 | admin_role.apply_to_user(target_user) |
| 122 | # Should only be in admin role group now |
| 123 | role_groups = target_user.groups.filter(name__startswith="role_") |
| 124 | assert role_groups.count() == 1 |
| 125 | assert role_groups.first().name == "role_admin" |
| 126 | |
| 127 | def test_remove_role_groups(self, viewer_role, target_user): |
| 128 | viewer_role.apply_to_user(target_user) |
| 129 | assert target_user.groups.filter(name__startswith="role_").count() == 1 |
| 130 | OrgRole.remove_role_groups(target_user) |
| 131 | assert target_user.groups.filter(name__startswith="role_").count() == 0 |
| 132 | |
| 133 | |
| 134 | # --- role_list view --- |
| 135 | |
| 136 | |
| 137 | @pytest.mark.django_db |
| 138 | class TestRoleListView: |
| 139 | def test_list_empty(self, admin_client, org): |
| 140 | response = admin_client.get(reverse("organization:role_list")) |
| 141 | assert response.status_code == 200 |
| 142 | assert "No roles defined" in response.content.decode() |
| 143 | |
| 144 | def test_list_with_roles(self, admin_client, org, roles): |
| 145 | response = admin_client.get(reverse("organization:role_list")) |
| 146 | assert response.status_code == 200 |
| 147 | content = response.content.decode() |
| 148 | assert "Admin" in content |
| 149 | assert "Manager" in content |
| 150 | assert "Developer" in content |
| 151 | assert "Viewer" in content |
| 152 | |
| 153 | def test_list_denied_for_no_perm(self, no_perm_client, org): |
| 154 | response = no_perm_client.get(reverse("organization:role_list")) |
| 155 | assert response.status_code == 403 |
| 156 | |
| 157 | def test_list_allowed_for_viewer(self, viewer_client, org, roles): |
| 158 | response = viewer_client.get(reverse("organization:role_list")) |
| 159 | assert response.status_code == 200 |
| 160 | |
| 161 | def test_list_denied_for_anon(self, client, org): |
| 162 | response = client.get(reverse("organization:role_list")) |
| 163 | assert response.status_code == 302 # redirect to login |
| 164 | |
| 165 | |
| 166 | # --- role_detail view --- |
| 167 | |
| 168 | |
| 169 | @pytest.mark.django_db |
| 170 | class TestRoleDetailView: |
| 171 | def test_detail_shows_role_info(self, admin_client, org, admin_role): |
| 172 | response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "admin"})) |
| 173 | assert response.status_code == 200 |
| 174 | content = response.content.decode() |
| 175 | assert "Admin" in content |
| 176 | assert "Full access" in content |
| 177 | |
| 178 | def test_detail_shows_permissions(self, admin_client, org, viewer_role): |
| 179 | response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"})) |
| 180 | assert response.status_code == 200 |
| 181 | content = response.content.decode() |
| 182 | assert "view_organization" in content |
| 183 | |
| 184 | def test_detail_shows_members(self, admin_client, org, viewer_role, target_user): |
| 185 | membership = OrganizationMember.objects.get(member=target_user, organization=org) |
| 186 | membership.role = viewer_role |
| 187 | membership.save() |
| 188 | response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"})) |
| 189 | assert response.status_code == 200 |
| 190 | assert "targetuser" in response.content.decode() |
| 191 | |
| 192 | def test_detail_denied_for_no_perm(self, no_perm_client, org, viewer_role): |
| 193 | response = no_perm_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"})) |
| 194 | assert response.status_code == 403 |
| 195 | |
| 196 | def test_detail_404_for_missing_role(self, admin_client, org): |
| 197 | response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "nonexistent"})) |
| 198 | assert response.status_code == 404 |
| 199 | |
| 200 | |
| 201 | # --- role_initialize view --- |
| 202 | |
| 203 | |
| 204 | @pytest.mark.django_db |
| 205 | class TestRoleInitializeView: |
| 206 | def test_initialize_creates_roles(self, admin_client, org): |
| 207 | assert OrgRole.objects.count() == 0 |
| 208 | response = admin_client.post(reverse("organization:role_initialize")) |
| 209 | assert response.status_code == 302 |
| 210 | assert OrgRole.objects.count() == 4 |
| 211 | |
| 212 | def test_initialize_denied_for_viewer(self, viewer_client, org): |
| 213 | response = viewer_client.post(reverse("organization:role_initialize")) |
| 214 | assert response.status_code == 403 |
| 215 | |
| 216 | def test_initialize_denied_for_no_perm(self, no_perm_client, org): |
| 217 | response = no_perm_client.post(reverse("organization:role_initialize")) |
| 218 | assert response.status_code == 403 |
| 219 | |
| 220 | def test_initialize_denied_for_anon(self, client, org): |
| 221 | response = client.post(reverse("organization:role_initialize")) |
| 222 | assert response.status_code == 302 # redirect to login |
| 223 | |
| 224 | def test_initialize_allowed_for_org_admin(self, org_admin_client, org): |
| 225 | response = org_admin_client.post(reverse("organization:role_initialize")) |
| 226 | assert response.status_code == 302 |
| 227 | assert OrgRole.objects.count() == 4 |
| 228 | |
| 229 | |
| 230 | # --- user_create with role --- |
| 231 | |
| 232 | |
| 233 | @pytest.mark.django_db |
| 234 | class TestUserCreateWithRole: |
| 235 | def test_create_user_with_role(self, admin_client, org, viewer_role): |
| 236 | response = admin_client.post( |
| 237 | reverse("organization:user_create"), |
| 238 | { |
| 239 | "username": "roleuser", |
| 240 | "email": "[email protected]", |
| 241 | "first_name": "Role", |
| 242 | "last_name": "User", |
| 243 | "password1": "Str0ng!Pass99", |
| 244 | "password2": "Str0ng!Pass99", |
| 245 | "role": viewer_role.pk, |
| 246 | }, |
| 247 | ) |
| 248 | assert response.status_code == 302 |
| 249 | user = User.objects.get(username="roleuser") |
| 250 | membership = OrganizationMember.objects.get(member=user, organization=org) |
| 251 | assert membership.role == viewer_role |
| 252 | # Verify role group was applied |
| 253 | assert user.groups.filter(name="role_viewer").exists() |
| 254 | |
| 255 | def test_create_user_without_role(self, admin_client, org, roles): |
| 256 | response = admin_client.post( |
| 257 | reverse("organization:user_create"), |
| 258 | { |
| 259 | "username": "noroleuser", |
| 260 | "email": "[email protected]", |
| 261 | "password1": "Str0ng!Pass99", |
| 262 | "password2": "Str0ng!Pass99", |
| 263 | }, |
| 264 | ) |
| 265 | assert response.status_code == 302 |
| 266 | user = User.objects.get(username="noroleuser") |
| 267 | membership = OrganizationMember.objects.get(member=user, organization=org) |
| 268 | assert membership.role is None |
| 269 | |
| 270 | def test_create_form_has_role_field(self, admin_client, org, roles): |
| 271 | response = admin_client.get(reverse("organization:user_create")) |
| 272 | assert response.status_code == 200 |
| 273 | content = response.content.decode() |
| 274 | assert "role" in content.lower() |
| 275 | |
| 276 | |
| 277 | # --- user_edit with role --- |
| 278 | |
| 279 | |
| 280 | @pytest.mark.django_db |
| 281 | class TestUserEditWithRole: |
| 282 | def test_edit_assigns_role(self, admin_client, org, target_user, viewer_role): |
| 283 | response = admin_client.post( |
| 284 | reverse("organization:user_edit", kwargs={"username": "targetuser"}), |
| 285 | { |
| 286 | "email": "[email protected]", |
| 287 | "first_name": "Target", |
| 288 | "last_name": "User", |
| 289 | "is_active": "on", |
| 290 | "role": viewer_role.pk, |
| 291 | }, |
| 292 | ) |
| 293 | assert response.status_code == 302 |
| 294 | membership = OrganizationMember.objects.get(member=target_user, organization=org) |
| 295 | assert membership.role == viewer_role |
| 296 | assert target_user.groups.filter(name="role_viewer").exists() |
| 297 | |
| 298 | def test_edit_changes_role(self, admin_client, org, target_user, viewer_role, admin_role): |
| 299 | # First assign viewer role |
| 300 | membership = OrganizationMember.objects.get(member=target_user, organization=org) |
| 301 | membership.role = viewer_role |
| 302 | membership.save() |
| 303 | viewer_role.apply_to_user(target_user) |
| 304 | |
| 305 | # Now change to admin role via edit |
| 306 | response = admin_client.post( |
| 307 | reverse("organization:user_edit", kwargs={"username": "targetuser"}), |
| 308 | { |
| 309 | "email": "[email protected]", |
| 310 | "first_name": "Target", |
| 311 | "last_name": "User", |
| 312 | "is_active": "on", |
| 313 | "role": admin_role.pk, |
| 314 | }, |
| 315 | ) |
| 316 | assert response.status_code == 302 |
| 317 | membership.refresh_from_db() |
| 318 | assert membership.role == admin_role |
| 319 | # Old role group should be gone, new one should be present |
| 320 | assert not target_user.groups.filter(name="role_viewer").exists() |
| 321 | assert target_user.groups.filter(name="role_admin").exists() |
| 322 | |
| 323 | def test_edit_removes_role(self, admin_client, org, target_user, viewer_role): |
| 324 | membership = OrganizationMember.objects.get(member=target_user, organization=org) |
| 325 | membership.role = viewer_role |
| 326 | membership.save() |
| 327 | viewer_role.apply_to_user(target_user) |
| 328 | |
| 329 | # Submit without role |
| 330 | response = admin_client.post( |
| 331 | reverse("organization:user_edit", kwargs={"username": "targetuser"}), |
| 332 | { |
| 333 | "email": "[email protected]", |
| 334 | "first_name": "Target", |
| 335 | "last_name": "User", |
| 336 | "is_active": "on", |
| 337 | # role intentionally omitted |
| 338 | }, |
| 339 | ) |
| 340 | assert response.status_code == 302 |
| 341 | membership.refresh_from_db() |
| 342 | assert membership.role is None |
| 343 | assert not target_user.groups.filter(name__startswith="role_").exists() |
| 344 | |
| 345 | def test_edit_form_pre_selects_role(self, admin_client, org, target_user, viewer_role): |
| 346 | membership = OrganizationMember.objects.get(member=target_user, organization=org) |
| 347 | membership.role = viewer_role |
| 348 | membership.save() |
| 349 | |
| 350 | response = admin_client.get(reverse("organization:user_edit", kwargs={"username": "targetuser"})) |
| 351 | assert response.status_code == 200 |
| 352 | content = response.content.decode() |
| 353 | # The viewer option should be selected |
| 354 | assert "selected" in content |
| 355 | |
| 356 | |
| 357 | # --- member_list role column --- |
| 358 | |
| 359 | |
| 360 | @p |