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).

lmata 2026-04-07 14:30 trunk
Commit f13cb5fe74eb970b153e0716268c968d2eea8b9373a5129a99921d17b3bcb264
--- core/context_processors.py
+++ core/context_processors.py
@@ -1,17 +1,32 @@
11
from pages.models import Page
2
-from projects.models import Project
2
+from projects.models import Project, ProjectGroup
33
44
55
def sidebar(request):
66
if not request.user.is_authenticated:
77
return {}
88
9
- projects = Project.objects.all()
9
+ projects = Project.objects.all().select_related("group")
1010
pages = Page.objects.filter(is_published=True)
1111
if request.user.has_perm("pages.change_page") or request.user.is_superuser:
1212
pages = Page.objects.all()
1313
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
+
1427
return {
1528
"sidebar_projects": projects,
29
+ "sidebar_grouped": grouped_projects,
30
+ "sidebar_ungrouped": ungrouped_projects,
1631
"sidebar_pages": pages,
1732
}
1833
--- 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 @@
2525
TEAM_VIEW = "organization.view_team"
2626
TEAM_ADD = "organization.add_team"
2727
TEAM_CHANGE = "organization.change_team"
2828
TEAM_DELETE = "organization.delete_team"
2929
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
+
3036
# Projects
3137
PROJECT_VIEW = "projects.view_project"
3238
PROJECT_ADD = "projects.add_project"
3339
PROJECT_CHANGE = "projects.change_project"
3440
PROJECT_DELETE = "projects.delete_project"
3541
--- 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 @@
11
from django.contrib import admin
22
33
from core.admin import BaseCoreAdmin
44
5
-from .models import Organization, OrganizationMember, Team
5
+from .models import Organization, OrganizationMember, OrgRole, Team
66
77
88
class OrganizationMemberInline(admin.TabularInline):
99
model = OrganizationMember
1010
extra = 0
@@ -15,10 +15,16 @@
1515
class OrganizationAdmin(BaseCoreAdmin):
1616
list_display = ("name", "slug", "website", "created_at")
1717
search_fields = ("name", "slug")
1818
inlines = [OrganizationMemberInline]
1919
20
+
21
+@admin.register(OrgRole)
22
+class OrgRoleAdmin(BaseCoreAdmin):
23
+ list_display = ("name", "slug", "is_default", "created_at")
24
+ filter_horizontal = ("permissions",)
25
+
2026
2127
@admin.register(Team)
2228
class TeamAdmin(BaseCoreAdmin):
2329
list_display = ("name", "slug", "organization", "created_at")
2430
search_fields = ("name", "slug")
@@ -26,8 +32,8 @@
2632
filter_horizontal = ("members",)
2733
2834
2935
@admin.register(OrganizationMember)
3036
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")
3339
raw_id_fields = ("member", "organization")
3440
--- 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 @@
11
from django import forms
22
from django.contrib.auth.models import User
33
from django.contrib.auth.password_validation import validate_password
44
from django.core.exceptions import ValidationError
55
6
-from .models import Organization, Team
6
+from .models import Organization, OrgRole, Team
77
88
tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
99
1010
1111
class OrganizationSettingsForm(forms.ModelForm):
@@ -66,10 +66,16 @@
6666
password2 = forms.CharField(
6767
label="Confirm Password",
6868
widget=forms.PasswordInput(attrs={"class": tw, "placeholder": "Confirm password"}),
6969
strip=False,
7070
)
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
+ )
7177
7278
class Meta:
7379
model = User
7480
fields = ["username", "email", "first_name", "last_name"]
7581
widgets = {
@@ -102,10 +108,17 @@
102108
user.save()
103109
return user
104110
105111
106112
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
+
107120
class Meta:
108121
model = User
109122
fields = ["email", "first_name", "last_name", "is_active", "is_staff"]
110123
widgets = {
111124
"email": forms.EmailInput(attrs={"class": tw, "placeholder": "[email protected]"}),
112125
113126
ADDED organization/management/__init__.py
114127
ADDED organization/management/commands/__init__.py
115128
ADDED organization/management/commands/seed_roles.py
116129
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
--- 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 @@
1212
all_objects = models.Manager()
1313
1414
class Meta:
1515
ordering = ["name"]
1616
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
+
1749
1850
class Team(BaseCoreModel):
1951
organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="teams")
2052
members = models.ManyToManyField("auth.User", blank=True, related_name="teams")
2153
@@ -28,10 +60,13 @@
2860
2961
class OrganizationMember(Tracking):
3062
is_active = models.BooleanField(default=True)
3163
member = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="memberships")
3264
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
+ )
3368
groups = models.ManyToManyField(Group, blank=True, related_name="org_memberships")
3469
3570
objects = ActiveManager()
3671
all_objects = models.Manager()
3772
3873
--- 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 @@
1414
path("members/create/", views.user_create, name="user_create"),
1515
path("members/<str:username>/", views.user_detail, name="user_detail"),
1616
path("members/<str:username>/edit/", views.user_edit, name="user_edit"),
1717
path("members/<str:username>/password/", views.user_password, name="user_password"),
1818
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"),
1923
# Teams
2024
path("teams/", views.team_list, name="team_list"),
2125
path("teams/create/", views.team_create, name="team_create"),
2226
path("teams/<slug:slug>/", views.team_detail, name="team_detail"),
2327
path("teams/<slug:slug>/edit/", views.team_update, name="team_update"),
2428
--- 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 @@
11
from django.contrib import messages
22
from django.contrib.auth.decorators import login_required
33
from django.contrib.auth.models import User
4
+from django.db import models
45
from django.http import HttpResponse
56
from django.shortcuts import get_object_or_404, redirect, render
67
78
from core.permissions import P
89
@@ -13,11 +14,11 @@
1314
TeamMemberAddForm,
1415
UserCreateForm,
1516
UserEditForm,
1617
UserPasswordForm,
1718
)
18
-from .models import Organization, OrganizationMember, Team
19
+from .models import Organization, OrganizationMember, OrgRole, Team
1920
2021
2122
def get_org():
2223
return Organization.objects.first()
2324
@@ -56,11 +57,11 @@
5657
5758
@login_required
5859
def member_list(request):
5960
P.ORGANIZATION_MEMBER_VIEW.check(request.user)
6061
org = get_org()
61
- members = OrganizationMember.objects.filter(organization=org).select_related("member")
62
+ members = OrganizationMember.objects.filter(organization=org).select_related("member", "role")
6263
6364
search = request.GET.get("search", "").strip()
6465
if search:
6566
members = members.filter(member__username__icontains=search)
6667
@@ -242,11 +243,14 @@
242243
243244
if request.method == "POST":
244245
form = UserCreateForm(request.POST)
245246
if form.is_valid():
246247
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)
248252
messages.success(request, f'User "{user.username}" created and added as member.')
249253
return redirect("organization:members")
250254
else:
251255
form = UserCreateForm()
252256
@@ -256,11 +260,13 @@
256260
@login_required
257261
def user_detail(request, username):
258262
P.ORGANIZATION_MEMBER_VIEW.check(request.user)
259263
org = get_org()
260264
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
+ )
262268
user_teams = Team.objects.filter(members=target_user, organization=org, deleted_at__isnull=True)
263269
264270
from fossil.user_keys import UserSSHKey
265271
266272
ssh_keys = UserSSHKey.objects.filter(user=target_user)
@@ -282,21 +288,37 @@
282288
283289
284290
@login_required
285291
def user_edit(request, username):
286292
_check_user_management_permission(request)
293
+ org = get_org()
287294
target_user = get_object_or_404(User, username=username)
288295
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
+ )
289299
290300
if request.method == "POST":
291301
form = UserEditForm(request.POST, instance=target_user, editing_self=editing_self)
292302
if form.is_valid():
293303
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)
294313
messages.success(request, f'User "{target_user.username}" updated.')
295314
return redirect("organization:members")
296315
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)
298320
299321
return render(
300322
request,
301323
"organization/user_form.html",
302324
{"form": form, "title": f"Edit {target_user.username}", "edit_user": target_user},
@@ -321,5 +343,59 @@
321343
return redirect("organization:user_detail", username=target_user.username)
322344
else:
323345
form = UserPasswordForm()
324346
325347
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")
326402
--- 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 @@
11
from django.contrib import admin
22
33
from core.admin import BaseCoreAdmin
44
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")
612
713
814
class ProjectTeamInline(admin.TabularInline):
915
model = ProjectTeam
1016
extra = 0
@@ -11,12 +17,12 @@
1117
raw_id_fields = ("team",)
1218
1319
1420
@admin.register(Project)
1521
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")
1824
search_fields = ("name", "slug", "description")
1925
inlines = [ProjectTeamInline]
2026
2127
2228
@admin.register(ProjectTeam)
2329
--- 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 @@
11
from django import forms
22
33
from organization.models import Team
44
5
-from .models import Project, ProjectTeam
5
+from .models import Project, ProjectGroup, ProjectTeam
66
77
tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
88
99
REPO_SOURCE_CHOICES = [
1010
("empty", "Create empty repository"),
1111
("fossil_url", "Clone from Fossil URL"),
1212
]
1313
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
+
1424
1525
class ProjectForm(forms.ModelForm):
1626
repo_source = forms.ChoiceField(
1727
choices=REPO_SOURCE_CHOICES,
1828
initial="empty",
@@ -25,15 +35,16 @@
2535
help_text="Fossil repository URL to clone from",
2636
)
2737
2838
class Meta:
2939
model = Project
30
- fields = ["name", "description", "visibility"]
40
+ fields = ["name", "description", "visibility", "group"]
3141
widgets = {
3242
"name": forms.TextInput(attrs={"class": tw, "placeholder": "Project name"}),
3343
"description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
3444
"visibility": forms.Select(attrs={"class": tw}),
45
+ "group": forms.Select(attrs={"class": tw}),
3546
}
3647
3748
def clean(self):
3849
cleaned = super().clean()
3950
repo_source = cleaned.get("repo_source", "empty")
4051
4152
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 @@
11
from django.db import models
22
33
from core.models import ActiveManager, BaseCoreModel, Tracking
44
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
+
518
619
class Project(BaseCoreModel):
720
class Visibility(models.TextChoices):
821
PUBLIC = "public", "Public"
922
INTERNAL = "internal", "Internal"
1023
PRIVATE = "private", "Private"
1124
1225
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
+ )
1334
visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
1435
teams = models.ManyToManyField("organization.Team", through="ProjectTeam", blank=True, related_name="projects")
1536
1637
objects = ActiveManager()
1738
all_objects = models.Manager()
1839
--- 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 @@
55
app_name = "projects"
66
77
urlpatterns = [
88
path("", views.project_list, name="list"),
99
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
1017
path("<slug:slug>/", views.project_detail, name="detail"),
1118
path("<slug:slug>/edit/", views.project_update, name="update"),
1219
path("<slug:slug>/delete/", views.project_delete, name="delete"),
1320
path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"),
1421
path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"),
1522
--- 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 @@
55
66
from core.permissions import P
77
from organization.models import Team
88
from organization.views import get_org
99
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
1212
1313
1414
@login_required
1515
def project_list(request):
1616
P.PROJECT_VIEW.check(request.user)
@@ -236,5 +236,84 @@
236236
return HttpResponse(status=200, headers={"HX-Redirect": f"/projects/{project.slug}/"})
237237
238238
return redirect("projects:detail", slug=project.slug)
239239
240240
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})
241320
242321
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 @@
4040
<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">
4141
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
4242
</svg>
4343
</button>
4444
<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" %}
8360
{% endfor %}
8461
{% if perms.projects.add_project %}
8562
<a href="{% url 'projects:create' %}"
8663
class="block rounded-md px-2 py-1.5 text-sm text-gray-600 hover:text-brand-light">
8764
+ New
@@ -88,10 +65,22 @@
8865
</a>
8966
{% endif %}
9067
</div>
9168
</div>
9269
{% 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 %}
9382
9483
<!-- Teams -->
9584
{% if perms.organization.view_team %}
9685
<a href="{% url 'organization:team_list' %}"
9786
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 @@
10089
<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" />
10190
</svg>
10291
<span x-show="!collapsed" class="truncate">Teams</span>
10392
</a>
10493
{% 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 %}
105106
106107
<!-- Knowledge Base section -->
107108
{% if perms.pages.view_page %}
108109
<div>
109110
<button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)"
@@ -147,11 +148,11 @@
147148
</a>
148149
149150
<!-- Settings -->
150151
{% if perms.organization.view_organization %}
151152
<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 %}"
153154
:title="collapsed ? 'Settings' : ''">
154155
<svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
155156
<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" />
156157
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
157158
</svg>
158159
--- 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 @@
33
<table class="min-w-full divide-y divide-gray-700">
44
<thead class="bg-gray-900">
55
<tr>
66
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Username</th>
77
<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>
89
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Status</th>
910
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Joined</th>
1011
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-400">Actions</th>
1112
</tr>
1213
</thead>
@@ -15,10 +16,17 @@
1516
<tr class="hover:bg-gray-700/50">
1617
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
1718
<a href="{% url 'organization:user_detail' username=membership.member.username %}" class="text-brand-light hover:text-brand">{{ membership.member.username }}</a>
1819
</td>
1920
<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>
2028
<td class="px-6 py-4 whitespace-nowrap">
2129
{% if membership.is_active %}
2230
<span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span>
2331
{% else %}
2432
<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 @@
3139
{% endif %}
3240
</td>
3341
</tr>
3442
{% empty %}
3543
<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>
3745
</tr>
3846
{% endfor %}
3947
</tbody>
4048
</table>
4149
</div>
4250
</div>
4351
4452
ADDED templates/organization/role_detail.html
4553
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">&larr; 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">&larr; 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">&larr; 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">&larr; 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 @@
6868
{% if perms.organization.view_team %}
6969
<a href="{% url 'organization:team_list' %}"
7070
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">
7171
Manage Teams
7272
</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>
7387
{% endif %}
7488
</div>
7589
</div>
7690
</div>
7791
{% endblock %}
7892
--- 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 @@
6868
{% if membership %}
6969
<div>
7070
<dt class="text-sm font-medium text-gray-400">Member Since</dt>
7171
<dd class="mt-1 text-sm text-gray-400">{{ membership.created_at|date:"N j, Y g:i a" }}</dd>
7272
</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>
7384
{% endif %}
7485
</dl>
7586
</div>
7687
</div>
7788
7889
7990
ADDED templates/projects/group_confirm_delete.html
8091
ADDED templates/projects/group_detail.html
8192
ADDED templates/projects/group_form.html
8293
ADDED templates/projects/group_list.html
8394
ADDED templates/projects/partials/group_table.html
8495
ADDED tests/test_project_groups.py
8596
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">&larr; 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">&larr; 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">&larr; 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">&larr; 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">&larr; Back to {{ group.name }}</a>
8
+ {% else %}
9
+ <a href="{% url 'projects:group_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; 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">&larr; Back to {{ group.name }}</a>
8 {% else %}
9 <a href="{% url 'projects:group_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; 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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button