FossilRepo

Add role CRUD, sample users, SQLite explorer, rename KB Role CRUD: create/edit/delete custom roles with permission picker UI grouped by app. Delete blocked when members assigned. Sample users seeded per role (admin/manager/developer/viewer). 34 tests. SQLite Explorer: visual schema map with category-colored table cards, SVG relationship graph, HTMX table detail browser, custom SQL query runner with injection prevention. Admin-only. 30 tests. Renamed Knowledge Base to Fossilrepo KB across all templates.

lmata 2026-04-07 16:12 trunk
Commit 01683ffbbb98d11fb89fe53546dd290be88d2a1a3df525a6d0897d4b143a5c57
--- fossil/urls.py
+++ fossil/urls.py
@@ -93,6 +93,10 @@
9393
path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"),
9494
path("branches/protect/<int:pk>/delete/", views.branch_protection_delete, name="branch_protection_delete"),
9595
# Artifact Shunning
9696
path("admin/shun/", views.shun_list_view, name="shun_list"),
9797
path("admin/shun/add/", views.shun_artifact, name="shun_artifact"),
98
+ # SQLite Explorer
99
+ path("explorer/", views.repo_explorer, name="explorer"),
100
+ path("explorer/table/<str:table_name>/", views.repo_explorer_table, name="explorer_table"),
101
+ path("explorer/query/", views.repo_explorer_query, name="explorer_query"),
98102
]
99103
--- fossil/urls.py
+++ fossil/urls.py
@@ -93,6 +93,10 @@
93 path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"),
94 path("branches/protect/<int:pk>/delete/", views.branch_protection_delete, name="branch_protection_delete"),
95 # Artifact Shunning
96 path("admin/shun/", views.shun_list_view, name="shun_list"),
97 path("admin/shun/add/", views.shun_artifact, name="shun_artifact"),
 
 
 
 
98 ]
99
--- fossil/urls.py
+++ fossil/urls.py
@@ -93,6 +93,10 @@
93 path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"),
94 path("branches/protect/<int:pk>/delete/", views.branch_protection_delete, name="branch_protection_delete"),
95 # Artifact Shunning
96 path("admin/shun/", views.shun_list_view, name="shun_list"),
97 path("admin/shun/add/", views.shun_artifact, name="shun_artifact"),
98 # SQLite Explorer
99 path("explorer/", views.repo_explorer, name="explorer"),
100 path("explorer/table/<str:table_name>/", views.repo_explorer_table, name="explorer_table"),
101 path("explorer/query/", views.repo_explorer_query, name="explorer_query"),
102 ]
103
--- fossil/views.py
+++ fossil/views.py
@@ -3969,5 +3969,232 @@
39693969
messages.success(request, f"Artifact {artifact_uuid[:12]}... has been permanently shunned.")
39703970
else:
39713971
messages.error(request, f"Failed to shun artifact: {result['message']}")
39723972
39733973
return redirect("fossil:shun_list", slug=slug)
3974
+
3975
+
3976
+# --- SQLite Explorer ---
3977
+
3978
+# Known relationships between Fossil tables (SQLite FKs are not enforced in .fossil files).
3979
+FOSSIL_RELATIONSHIPS = [
3980
+ ("event", "blob", "objid -> rid"),
3981
+ ("mlink", "blob", "mid -> rid, fid -> rid"),
3982
+ ("plink", "blob", "cid -> rid, pid -> rid"),
3983
+ ("tagxref", "tag", "tagid -> tagid"),
3984
+ ("tagxref", "blob", "srcid -> rid, origid -> rid"),
3985
+ ("delta", "blob", "rid -> rid, srcid -> rid"),
3986
+ ("leaf", "blob", "rid -> rid"),
3987
+ ("phantom", "blob", "rid -> rid"),
3988
+ ("ticketchng", "blob", "tkt_rid -> rid"),
3989
+ ("forumpost", "blob", "fpid -> rid"),
3990
+]
3991
+
3992
+# Category colors for the schema visualization.
3993
+FOSSIL_TABLE_CATEGORIES = {
3994
+ # VCS core
3995
+ "blob": "blue",
3996
+ "delta": "blue",
3997
+ "event": "blue",
3998
+ "mlink": "blue",
3999
+ "plink": "blue",
4000
+ "leaf": "blue",
4001
+ "phantom": "blue",
4002
+ "rcvfrom": "blue",
4003
+ "filename": "blue",
4004
+ "repo_cksum": "blue",
4005
+ "config": "blue",
4006
+ # Tagging / branching
4007
+ "tag": "indigo",
4008
+ "tagxref": "indigo",
4009
+ # Tickets
4010
+ "ticket": "green",
4011
+ "ticketchng": "green",
4012
+ # Wiki
4013
+ "backlink": "purple",
4014
+ # Forum
4015
+ "forumpost": "orange",
4016
+ "forumthread": "orange",
4017
+ # Other
4018
+ "unversioned": "gray",
4019
+ "shun": "gray",
4020
+ "private": "gray",
4021
+ "concealed": "gray",
4022
+ "accesslog": "gray",
4023
+ "user": "gray",
4024
+}
4025
+
4026
+# Regex for validating table names (alphanumerics + underscore, must start with letter or underscore).
4027
+_TABLE_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
4028
+
4029
+
4030
+@login_required
4031
+def repo_explorer(request, slug):
4032
+ """Main schema explorer page -- admin only."""
4033
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, require="admin")
4034
+
4035
+ with reader:
4036
+ conn = reader.conn
4037
+ cursor = conn.cursor()
4038
+
4039
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4040
+ table_names = [row[0] for row in cursor.fetchall()]
4041
+
4042
+ tables = []
4043
+ for name in table_names:
4044
+ cursor.execute(f"SELECT count(*) FROM [{name}]") # noqa: S608
4045
+ count = cursor.fetchone()[0]
4046
+ category = FOSSIL_TABLE_CATEGORIES.get(name, "gray")
4047
+ tables.append({"name": name, "count": count, "category": category})
4048
+
4049
+ # Build relationships that involve tables actually present in this repo.
4050
+ present = {t["name"] for t in tables}
4051
+ relationships = [r for r in FOSSIL_RELATIONSHIPS if r[0] in present and r[1] in present]
4052
+
4053
+ import json
4054
+
4055
+ return render(
4056
+ request,
4057
+ "fossil/explorer.html",
4058
+ {
4059
+ "project": project,
4060
+ "fossil_repo": fossil_repo,
4061
+ "tables": tables,
4062
+ "relationships": relationships,
4063
+ "relationships_json": json.dumps(relationships),
4064
+ "tables_json": json.dumps(tables),
4065
+ "active_tab": "explorer",
4066
+ },
4067
+ )
4068
+
4069
+
4070
+@login_required
4071
+def repo_explorer_table(request, slug, table_name):
4072
+ """Return table detail as an HTMX partial -- admin only."""
4073
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, require="admin")
4074
+
4075
+ if not _TABLE_NAME_RE.match(table_name):
4076
+ raise Http404("Invalid table name")
4077
+
4078
+ with reader:
4079
+ conn = reader.conn
4080
+ cursor = conn.cursor()
4081
+
4082
+ # Verify the table exists.
4083
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
4084
+ if not cursor.fetchone():
4085
+ raise Http404("Table not found")
4086
+
4087
+ # Column metadata.
4088
+ cursor.execute(f"PRAGMA table_info([{table_name}])")
4089
+ columns = [{"cid": row[0], "name": row[1], "type": row[2] or "BLOB", "notnull": row[3], "pk": row[5]} for row in cursor.fetchall()]
4090
+
4091
+ # Paginated rows.
4092
+ try:
4093
+ page = max(1, int(request.GET.get("page", 1)))
4094
+ except (ValueError, TypeError):
4095
+ page = 1
4096
+ per_page = 25
4097
+ offset = (page - 1) * per_page
4098
+
4099
+ cursor.execute(f"SELECT * FROM [{table_name}] LIMIT ? OFFSET ?", (per_page, offset)) # noqa: S608
4100
+ col_names = [desc[0] for desc in cursor.description] if cursor.description else []
4101
+ raw_rows = cursor.fetchall()
4102
+
4103
+ # Truncate long binary/text values for display.
4104
+ rows = []
4105
+ for raw in raw_rows:
4106
+ display = []
4107
+ for cell in raw:
4108
+ if isinstance(cell, bytes):
4109
+ display.append(f"<{len(cell)} bytes>")
4110
+ elif isinstance(cell, str) and len(cell) > 200:
4111
+ display.append(cell[:200] + "...")
4112
+ else:
4113
+ display.append(cell)
4114
+ rows.append(display)
4115
+
4116
+ cursor.execute(f"SELECT count(*) FROM [{table_name}]") # noqa: S608
4117
+ total = cursor.fetchone()[0]
4118
+
4119
+ return render(
4120
+ request,
4121
+ "fossil/partials/explorer_table.html",
4122
+ {
4123
+ "project": project,
4124
+ "fossil_repo": fossil_repo,
4125
+ "table_name": table_name,
4126
+ "columns": columns,
4127
+ "col_names": col_names,
4128
+ "rows": rows,
4129
+ "total": total,
4130
+ "page": page,
4131
+ "per_page": per_page,
4132
+ "has_next": offset + per_page < total,
4133
+ "has_prev": page > 1,
4134
+ },
4135
+ )
4136
+
4137
+
4138
+@login_required
4139
+def repo_explorer_query(request, slug):
4140
+ """Run a custom read-only SQL query against the .fossil file -- admin only."""
4141
+ from fossil.ticket_reports import TicketReport
4142
+
4143
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, require="admin")
4144
+
4145
+ sql = request.GET.get("sql", "").strip()
4146
+ results = None
4147
+ columns = []
4148
+ error = ""
4149
+
4150
+ if sql:
4151
+ validation_error = TicketReport.validate_sql(sql)
4152
+ if validation_error:
4153
+ error = validation_error
4154
+ else:
4155
+ try:
4156
+ with reader:
4157
+ cursor = reader.conn.cursor()
4158
+ cursor.execute(sql)
4159
+ columns = [desc[0] for desc in cursor.description] if cursor.description else []
4160
+ raw = cursor.fetchmany(500)
4161
+ # Truncate long values for display.
4162
+ results = []
4163
+ for raw_row in raw:
4164
+ display = []
4165
+ for cell in raw_row:
4166
+ if isinstance(cell, bytes):
4167
+ display.append(f"<{len(cell)} bytes>")
4168
+ elif isinstance(cell, str) and len(cell) > 200:
4169
+ display.append(cell[:200] + "...")
4170
+ else:
4171
+ display.append(cell)
4172
+ results.append(display)
4173
+ except Exception as e:
4174
+ error = str(e)
4175
+
4176
+ # Provide table names for the helper sidebar.
4177
+ table_names = []
4178
+ if not sql or error:
4179
+ try:
4180
+ with reader:
4181
+ cursor = reader.conn.cursor()
4182
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4183
+ table_names = [row[0] for row in cursor.fetchall()]
4184
+ except Exception:
4185
+ pass
4186
+
4187
+ return render(
4188
+ request,
4189
+ "fossil/explorer_query.html",
4190
+ {
4191
+ "project": project,
4192
+ "fossil_repo": fossil_repo,
4193
+ "sql": sql,
4194
+ "columns": columns,
4195
+ "results": results,
4196
+ "error": error,
4197
+ "table_names": table_names,
4198
+ "active_tab": "explorer",
4199
+ },
4200
+ )
39744201
--- fossil/views.py
+++ fossil/views.py
@@ -3969,5 +3969,232 @@
3969 messages.success(request, f"Artifact {artifact_uuid[:12]}... has been permanently shunned.")
3970 else:
3971 messages.error(request, f"Failed to shun artifact: {result['message']}")
3972
3973 return redirect("fossil:shun_list", slug=slug)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3974
--- fossil/views.py
+++ fossil/views.py
@@ -3969,5 +3969,232 @@
3969 messages.success(request, f"Artifact {artifact_uuid[:12]}... has been permanently shunned.")
3970 else:
3971 messages.error(request, f"Failed to shun artifact: {result['message']}")
3972
3973 return redirect("fossil:shun_list", slug=slug)
3974
3975
3976 # --- SQLite Explorer ---
3977
3978 # Known relationships between Fossil tables (SQLite FKs are not enforced in .fossil files).
3979 FOSSIL_RELATIONSHIPS = [
3980 ("event", "blob", "objid -> rid"),
3981 ("mlink", "blob", "mid -> rid, fid -> rid"),
3982 ("plink", "blob", "cid -> rid, pid -> rid"),
3983 ("tagxref", "tag", "tagid -> tagid"),
3984 ("tagxref", "blob", "srcid -> rid, origid -> rid"),
3985 ("delta", "blob", "rid -> rid, srcid -> rid"),
3986 ("leaf", "blob", "rid -> rid"),
3987 ("phantom", "blob", "rid -> rid"),
3988 ("ticketchng", "blob", "tkt_rid -> rid"),
3989 ("forumpost", "blob", "fpid -> rid"),
3990 ]
3991
3992 # Category colors for the schema visualization.
3993 FOSSIL_TABLE_CATEGORIES = {
3994 # VCS core
3995 "blob": "blue",
3996 "delta": "blue",
3997 "event": "blue",
3998 "mlink": "blue",
3999 "plink": "blue",
4000 "leaf": "blue",
4001 "phantom": "blue",
4002 "rcvfrom": "blue",
4003 "filename": "blue",
4004 "repo_cksum": "blue",
4005 "config": "blue",
4006 # Tagging / branching
4007 "tag": "indigo",
4008 "tagxref": "indigo",
4009 # Tickets
4010 "ticket": "green",
4011 "ticketchng": "green",
4012 # Wiki
4013 "backlink": "purple",
4014 # Forum
4015 "forumpost": "orange",
4016 "forumthread": "orange",
4017 # Other
4018 "unversioned": "gray",
4019 "shun": "gray",
4020 "private": "gray",
4021 "concealed": "gray",
4022 "accesslog": "gray",
4023 "user": "gray",
4024 }
4025
4026 # Regex for validating table names (alphanumerics + underscore, must start with letter or underscore).
4027 _TABLE_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
4028
4029
4030 @login_required
4031 def repo_explorer(request, slug):
4032 """Main schema explorer page -- admin only."""
4033 project, fossil_repo, reader = _get_repo_and_reader(slug, request, require="admin")
4034
4035 with reader:
4036 conn = reader.conn
4037 cursor = conn.cursor()
4038
4039 cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4040 table_names = [row[0] for row in cursor.fetchall()]
4041
4042 tables = []
4043 for name in table_names:
4044 cursor.execute(f"SELECT count(*) FROM [{name}]") # noqa: S608
4045 count = cursor.fetchone()[0]
4046 category = FOSSIL_TABLE_CATEGORIES.get(name, "gray")
4047 tables.append({"name": name, "count": count, "category": category})
4048
4049 # Build relationships that involve tables actually present in this repo.
4050 present = {t["name"] for t in tables}
4051 relationships = [r for r in FOSSIL_RELATIONSHIPS if r[0] in present and r[1] in present]
4052
4053 import json
4054
4055 return render(
4056 request,
4057 "fossil/explorer.html",
4058 {
4059 "project": project,
4060 "fossil_repo": fossil_repo,
4061 "tables": tables,
4062 "relationships": relationships,
4063 "relationships_json": json.dumps(relationships),
4064 "tables_json": json.dumps(tables),
4065 "active_tab": "explorer",
4066 },
4067 )
4068
4069
4070 @login_required
4071 def repo_explorer_table(request, slug, table_name):
4072 """Return table detail as an HTMX partial -- admin only."""
4073 project, fossil_repo, reader = _get_repo_and_reader(slug, request, require="admin")
4074
4075 if not _TABLE_NAME_RE.match(table_name):
4076 raise Http404("Invalid table name")
4077
4078 with reader:
4079 conn = reader.conn
4080 cursor = conn.cursor()
4081
4082 # Verify the table exists.
4083 cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
4084 if not cursor.fetchone():
4085 raise Http404("Table not found")
4086
4087 # Column metadata.
4088 cursor.execute(f"PRAGMA table_info([{table_name}])")
4089 columns = [{"cid": row[0], "name": row[1], "type": row[2] or "BLOB", "notnull": row[3], "pk": row[5]} for row in cursor.fetchall()]
4090
4091 # Paginated rows.
4092 try:
4093 page = max(1, int(request.GET.get("page", 1)))
4094 except (ValueError, TypeError):
4095 page = 1
4096 per_page = 25
4097 offset = (page - 1) * per_page
4098
4099 cursor.execute(f"SELECT * FROM [{table_name}] LIMIT ? OFFSET ?", (per_page, offset)) # noqa: S608
4100 col_names = [desc[0] for desc in cursor.description] if cursor.description else []
4101 raw_rows = cursor.fetchall()
4102
4103 # Truncate long binary/text values for display.
4104 rows = []
4105 for raw in raw_rows:
4106 display = []
4107 for cell in raw:
4108 if isinstance(cell, bytes):
4109 display.append(f"<{len(cell)} bytes>")
4110 elif isinstance(cell, str) and len(cell) > 200:
4111 display.append(cell[:200] + "...")
4112 else:
4113 display.append(cell)
4114 rows.append(display)
4115
4116 cursor.execute(f"SELECT count(*) FROM [{table_name}]") # noqa: S608
4117 total = cursor.fetchone()[0]
4118
4119 return render(
4120 request,
4121 "fossil/partials/explorer_table.html",
4122 {
4123 "project": project,
4124 "fossil_repo": fossil_repo,
4125 "table_name": table_name,
4126 "columns": columns,
4127 "col_names": col_names,
4128 "rows": rows,
4129 "total": total,
4130 "page": page,
4131 "per_page": per_page,
4132 "has_next": offset + per_page < total,
4133 "has_prev": page > 1,
4134 },
4135 )
4136
4137
4138 @login_required
4139 def repo_explorer_query(request, slug):
4140 """Run a custom read-only SQL query against the .fossil file -- admin only."""
4141 from fossil.ticket_reports import TicketReport
4142
4143 project, fossil_repo, reader = _get_repo_and_reader(slug, request, require="admin")
4144
4145 sql = request.GET.get("sql", "").strip()
4146 results = None
4147 columns = []
4148 error = ""
4149
4150 if sql:
4151 validation_error = TicketReport.validate_sql(sql)
4152 if validation_error:
4153 error = validation_error
4154 else:
4155 try:
4156 with reader:
4157 cursor = reader.conn.cursor()
4158 cursor.execute(sql)
4159 columns = [desc[0] for desc in cursor.description] if cursor.description else []
4160 raw = cursor.fetchmany(500)
4161 # Truncate long values for display.
4162 results = []
4163 for raw_row in raw:
4164 display = []
4165 for cell in raw_row:
4166 if isinstance(cell, bytes):
4167 display.append(f"<{len(cell)} bytes>")
4168 elif isinstance(cell, str) and len(cell) > 200:
4169 display.append(cell[:200] + "...")
4170 else:
4171 display.append(cell)
4172 results.append(display)
4173 except Exception as e:
4174 error = str(e)
4175
4176 # Provide table names for the helper sidebar.
4177 table_names = []
4178 if not sql or error:
4179 try:
4180 with reader:
4181 cursor = reader.conn.cursor()
4182 cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4183 table_names = [row[0] for row in cursor.fetchall()]
4184 except Exception:
4185 pass
4186
4187 return render(
4188 request,
4189 "fossil/explorer_query.html",
4190 {
4191 "project": project,
4192 "fossil_repo": fossil_repo,
4193 "sql": sql,
4194 "columns": columns,
4195 "results": results,
4196 "error": error,
4197 "table_names": table_names,
4198 "active_tab": "explorer",
4199 },
4200 )
4201
--- organization/forms.py
+++ organization/forms.py
@@ -1,7 +1,9 @@
1
+import contextlib
2
+
13
from django import forms
2
-from django.contrib.auth.models import User
4
+from django.contrib.auth.models import Permission, User
35
from django.contrib.auth.password_validation import validate_password
46
from django.core.exceptions import ValidationError
57
68
from .models import Organization, OrgRole, Team
79
@@ -161,5 +163,68 @@
161163
p1 = cleaned_data.get("new_password1")
162164
p2 = cleaned_data.get("new_password2")
163165
if p1 and p2 and p1 != p2:
164166
self.add_error("new_password2", "Passwords do not match.")
165167
return cleaned_data
168
+
169
+
170
+# App labels whose permissions appear in the role permission picker
171
+ROLE_APP_LABELS = ["organization", "projects", "pages", "fossil"]
172
+
173
+ROLE_APP_DISPLAY = {
174
+ "organization": "Organization",
175
+ "projects": "Projects",
176
+ "pages": "Pages",
177
+ "fossil": "Fossil",
178
+}
179
+
180
+
181
+class OrgRoleForm(forms.ModelForm):
182
+ permissions = forms.ModelMultipleChoiceField(
183
+ queryset=Permission.objects.none(),
184
+ required=False,
185
+ widget=forms.CheckboxSelectMultiple,
186
+ )
187
+
188
+ class Meta:
189
+ model = OrgRole
190
+ fields = ["name", "description", "is_default"]
191
+ widgets = {
192
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "Role name"}),
193
+ "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
194
+ "is_default": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand focus:ring-brand"}),
195
+ }
196
+
197
+ def __init__(self, *args, **kwargs):
198
+ super().__init__(*args, **kwargs)
199
+ self.fields["permissions"].queryset = (
200
+ Permission.objects.filter(content_type__app_label__in=ROLE_APP_LABELS)
201
+ .select_related("content_type")
202
+ .order_by("content_type__app_label", "codename")
203
+ )
204
+
205
+ if self.instance.pk:
206
+ self.fields["permissions"].initial = self.instance.permissions.values_list("pk", flat=True)
207
+
208
+ def grouped_permissions(self):
209
+ """Return permissions grouped by app label for the template."""
210
+ grouped = {}
211
+ selected_ids = set()
212
+ if self.instance.pk:
213
+ selected_ids = set(self.instance.permissions.values_list("pk", flat=True))
214
+ elif self.data:
215
+ # Handle POST data (validation errors, re-render)
216
+ with contextlib.suppress(ValueError, TypeError):
217
+ selected_ids = set(int(v) for v in self.data.getlist("permissions"))
218
+
219
+ for perm in self.fields["permissions"].queryset:
220
+ app = perm.content_type.app_label
221
+ label = ROLE_APP_DISPLAY.get(app, app.title())
222
+ grouped.setdefault(label, [])
223
+ grouped[label].append({"perm": perm, "checked": perm.pk in selected_ids})
224
+ return grouped
225
+
226
+ def save(self, commit=True):
227
+ role = super().save(commit=commit)
228
+ if commit:
229
+ role.permissions.set(self.cleaned_data["permissions"])
230
+ return role
166231
--- organization/forms.py
+++ organization/forms.py
@@ -1,7 +1,9 @@
 
 
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
@@ -161,5 +163,68 @@
161 p1 = cleaned_data.get("new_password1")
162 p2 = cleaned_data.get("new_password2")
163 if p1 and p2 and p1 != p2:
164 self.add_error("new_password2", "Passwords do not match.")
165 return cleaned_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
--- organization/forms.py
+++ organization/forms.py
@@ -1,7 +1,9 @@
1 import contextlib
2
3 from django import forms
4 from django.contrib.auth.models import Permission, User
5 from django.contrib.auth.password_validation import validate_password
6 from django.core.exceptions import ValidationError
7
8 from .models import Organization, OrgRole, Team
9
@@ -161,5 +163,68 @@
163 p1 = cleaned_data.get("new_password1")
164 p2 = cleaned_data.get("new_password2")
165 if p1 and p2 and p1 != p2:
166 self.add_error("new_password2", "Passwords do not match.")
167 return cleaned_data
168
169
170 # App labels whose permissions appear in the role permission picker
171 ROLE_APP_LABELS = ["organization", "projects", "pages", "fossil"]
172
173 ROLE_APP_DISPLAY = {
174 "organization": "Organization",
175 "projects": "Projects",
176 "pages": "Pages",
177 "fossil": "Fossil",
178 }
179
180
181 class OrgRoleForm(forms.ModelForm):
182 permissions = forms.ModelMultipleChoiceField(
183 queryset=Permission.objects.none(),
184 required=False,
185 widget=forms.CheckboxSelectMultiple,
186 )
187
188 class Meta:
189 model = OrgRole
190 fields = ["name", "description", "is_default"]
191 widgets = {
192 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Role name"}),
193 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
194 "is_default": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand focus:ring-brand"}),
195 }
196
197 def __init__(self, *args, **kwargs):
198 super().__init__(*args, **kwargs)
199 self.fields["permissions"].queryset = (
200 Permission.objects.filter(content_type__app_label__in=ROLE_APP_LABELS)
201 .select_related("content_type")
202 .order_by("content_type__app_label", "codename")
203 )
204
205 if self.instance.pk:
206 self.fields["permissions"].initial = self.instance.permissions.values_list("pk", flat=True)
207
208 def grouped_permissions(self):
209 """Return permissions grouped by app label for the template."""
210 grouped = {}
211 selected_ids = set()
212 if self.instance.pk:
213 selected_ids = set(self.instance.permissions.values_list("pk", flat=True))
214 elif self.data:
215 # Handle POST data (validation errors, re-render)
216 with contextlib.suppress(ValueError, TypeError):
217 selected_ids = set(int(v) for v in self.data.getlist("permissions"))
218
219 for perm in self.fields["permissions"].queryset:
220 app = perm.content_type.app_label
221 label = ROLE_APP_DISPLAY.get(app, app.title())
222 grouped.setdefault(label, [])
223 grouped[label].append({"perm": perm, "checked": perm.pk in selected_ids})
224 return grouped
225
226 def save(self, commit=True):
227 role = super().save(commit=commit)
228 if commit:
229 role.permissions.set(self.cleaned_data["permissions"])
230 return role
231
--- organization/urls.py
+++ organization/urls.py
@@ -16,12 +16,15 @@
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"),
1919
# Roles
2020
path("roles/", views.role_list, name="role_list"),
21
+ path("roles/create/", views.role_create, name="role_create"),
2122
path("roles/initialize/", views.role_initialize, name="role_initialize"),
2223
path("roles/<slug:slug>/", views.role_detail, name="role_detail"),
24
+ path("roles/<slug:slug>/edit/", views.role_edit, name="role_edit"),
25
+ path("roles/<slug:slug>/delete/", views.role_delete, name="role_delete"),
2326
# Audit log
2427
path("audit/", views.audit_log, name="audit_log"),
2528
# Teams
2629
path("teams/", views.team_list, name="team_list"),
2730
path("teams/create/", views.team_create, name="team_create"),
2831
--- organization/urls.py
+++ organization/urls.py
@@ -16,12 +16,15 @@
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 # Audit log
24 path("audit/", views.audit_log, name="audit_log"),
25 # Teams
26 path("teams/", views.team_list, name="team_list"),
27 path("teams/create/", views.team_create, name="team_create"),
28
--- organization/urls.py
+++ organization/urls.py
@@ -16,12 +16,15 @@
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/create/", views.role_create, name="role_create"),
22 path("roles/initialize/", views.role_initialize, name="role_initialize"),
23 path("roles/<slug:slug>/", views.role_detail, name="role_detail"),
24 path("roles/<slug:slug>/edit/", views.role_edit, name="role_edit"),
25 path("roles/<slug:slug>/delete/", views.role_delete, name="role_delete"),
26 # Audit log
27 path("audit/", views.audit_log, name="audit_log"),
28 # Teams
29 path("teams/", views.team_list, name="team_list"),
30 path("teams/create/", views.team_create, name="team_create"),
31
--- organization/views.py
+++ organization/views.py
@@ -9,10 +9,11 @@
99
from core.permissions import P
1010
1111
from .forms import (
1212
MemberAddForm,
1313
OrganizationSettingsForm,
14
+ OrgRoleForm,
1415
TeamForm,
1516
TeamMemberAddForm,
1617
UserCreateForm,
1718
UserEditForm,
1819
UserPasswordForm,
@@ -394,10 +395,78 @@
394395
request,
395396
"organization/role_detail.html",
396397
{"role": role, "grouped_permissions": grouped, "role_members": role_members},
397398
)
398399
400
+
401
+@login_required
402
+def role_create(request):
403
+ P.ORGANIZATION_CHANGE.check(request.user)
404
+
405
+ if request.method == "POST":
406
+ form = OrgRoleForm(request.POST)
407
+ if form.is_valid():
408
+ role = form.save(commit=False)
409
+ role.created_by = request.user
410
+ role.save()
411
+ form.save_m2m()
412
+ role.permissions.set(form.cleaned_data["permissions"])
413
+ messages.success(request, f'Role "{role.name}" created.')
414
+ return redirect("organization:role_detail", slug=role.slug)
415
+ else:
416
+ form = OrgRoleForm()
417
+
418
+ return render(request, "organization/role_form.html", {"form": form, "title": "New Role"})
419
+
420
+
421
+@login_required
422
+def role_edit(request, slug):
423
+ P.ORGANIZATION_CHANGE.check(request.user)
424
+ role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
425
+
426
+ if request.method == "POST":
427
+ form = OrgRoleForm(request.POST, instance=role)
428
+ if form.is_valid():
429
+ role = form.save(commit=False)
430
+ role.updated_by = request.user
431
+ role.save()
432
+ role.permissions.set(form.cleaned_data["permissions"])
433
+ messages.success(request, f'Role "{role.name}" updated.')
434
+ return redirect("organization:role_detail", slug=role.slug)
435
+ else:
436
+ form = OrgRoleForm(instance=role)
437
+
438
+ return render(request, "organization/role_form.html", {"form": form, "role": role, "title": f"Edit {role.name}"})
439
+
440
+
441
+@login_required
442
+def role_delete(request, slug):
443
+ P.ORGANIZATION_CHANGE.check(request.user)
444
+ role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
445
+ active_members = OrganizationMember.objects.filter(role=role, deleted_at__isnull=True)
446
+
447
+ if request.method == "POST":
448
+ if active_members.exists():
449
+ messages.error(
450
+ request, f'Cannot delete role "{role.name}" -- it has {active_members.count()} active member(s). Reassign them first.'
451
+ )
452
+ return redirect("organization:role_detail", slug=role.slug)
453
+
454
+ role.soft_delete(user=request.user)
455
+ messages.success(request, f'Role "{role.name}" deleted.')
456
+
457
+ if request.headers.get("HX-Request"):
458
+ return HttpResponse(status=200, headers={"HX-Redirect": "/settings/roles/"})
459
+
460
+ return redirect("organization:role_list")
461
+
462
+ return render(
463
+ request,
464
+ "organization/role_confirm_delete.html",
465
+ {"role": role, "active_members": active_members},
466
+ )
467
+
399468
400469
@login_required
401470
def audit_log(request):
402471
"""Unified audit log across all tracked models. Requires superuser or org admin."""
403472
import math
404473
--- organization/views.py
+++ organization/views.py
@@ -9,10 +9,11 @@
9 from core.permissions import P
10
11 from .forms import (
12 MemberAddForm,
13 OrganizationSettingsForm,
 
14 TeamForm,
15 TeamMemberAddForm,
16 UserCreateForm,
17 UserEditForm,
18 UserPasswordForm,
@@ -394,10 +395,78 @@
394 request,
395 "organization/role_detail.html",
396 {"role": role, "grouped_permissions": grouped, "role_members": role_members},
397 )
398
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
400 @login_required
401 def audit_log(request):
402 """Unified audit log across all tracked models. Requires superuser or org admin."""
403 import math
404
--- organization/views.py
+++ organization/views.py
@@ -9,10 +9,11 @@
9 from core.permissions import P
10
11 from .forms import (
12 MemberAddForm,
13 OrganizationSettingsForm,
14 OrgRoleForm,
15 TeamForm,
16 TeamMemberAddForm,
17 UserCreateForm,
18 UserEditForm,
19 UserPasswordForm,
@@ -394,10 +395,78 @@
395 request,
396 "organization/role_detail.html",
397 {"role": role, "grouped_permissions": grouped, "role_members": role_members},
398 )
399
400
401 @login_required
402 def role_create(request):
403 P.ORGANIZATION_CHANGE.check(request.user)
404
405 if request.method == "POST":
406 form = OrgRoleForm(request.POST)
407 if form.is_valid():
408 role = form.save(commit=False)
409 role.created_by = request.user
410 role.save()
411 form.save_m2m()
412 role.permissions.set(form.cleaned_data["permissions"])
413 messages.success(request, f'Role "{role.name}" created.')
414 return redirect("organization:role_detail", slug=role.slug)
415 else:
416 form = OrgRoleForm()
417
418 return render(request, "organization/role_form.html", {"form": form, "title": "New Role"})
419
420
421 @login_required
422 def role_edit(request, slug):
423 P.ORGANIZATION_CHANGE.check(request.user)
424 role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
425
426 if request.method == "POST":
427 form = OrgRoleForm(request.POST, instance=role)
428 if form.is_valid():
429 role = form.save(commit=False)
430 role.updated_by = request.user
431 role.save()
432 role.permissions.set(form.cleaned_data["permissions"])
433 messages.success(request, f'Role "{role.name}" updated.')
434 return redirect("organization:role_detail", slug=role.slug)
435 else:
436 form = OrgRoleForm(instance=role)
437
438 return render(request, "organization/role_form.html", {"form": form, "role": role, "title": f"Edit {role.name}"})
439
440
441 @login_required
442 def role_delete(request, slug):
443 P.ORGANIZATION_CHANGE.check(request.user)
444 role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
445 active_members = OrganizationMember.objects.filter(role=role, deleted_at__isnull=True)
446
447 if request.method == "POST":
448 if active_members.exists():
449 messages.error(
450 request, f'Cannot delete role "{role.name}" -- it has {active_members.count()} active member(s). Reassign them first.'
451 )
452 return redirect("organization:role_detail", slug=role.slug)
453
454 role.soft_delete(user=request.user)
455 messages.success(request, f'Role "{role.name}" deleted.')
456
457 if request.headers.get("HX-Request"):
458 return HttpResponse(status=200, headers={"HX-Redirect": "/settings/roles/"})
459
460 return redirect("organization:role_list")
461
462 return render(
463 request,
464 "organization/role_confirm_delete.html",
465 {"role": role, "active_members": active_members},
466 )
467
468
469 @login_required
470 def audit_log(request):
471 """Unified audit log across all tracked models. Requires superuser or org admin."""
472 import math
473
--- templates/dashboard.html
+++ templates/dashboard.html
@@ -89,11 +89,11 @@
8989
<p class="mt-1 text-xs text-gray-500">Organize members into teams</p>
9090
</a>
9191
{% endif %}
9292
{% if perms.pages.view_page %}
9393
<a href="{% url 'pages:list' %}" class="block rounded-lg bg-gray-800 border border-gray-700 p-4 hover:border-brand transition-colors">
94
- <h3 class="text-sm font-semibold text-gray-100">Knowledge Base</h3>
94
+ <h3 class="text-sm font-semibold text-gray-100">Fossilrepo KB</h3>
9595
<p class="mt-1 text-xs text-gray-500">Guides, runbooks, documentation</p>
9696
</a>
9797
{% endif %}
9898
{% if perms.organization.view_organization %}
9999
<a href="{% url 'organization:settings' %}" class="block rounded-lg bg-gray-800 border border-gray-700 p-4 hover:border-brand transition-colors">
100100
--- templates/dashboard.html
+++ templates/dashboard.html
@@ -89,11 +89,11 @@
89 <p class="mt-1 text-xs text-gray-500">Organize members into teams</p>
90 </a>
91 {% endif %}
92 {% if perms.pages.view_page %}
93 <a href="{% url 'pages:list' %}" class="block rounded-lg bg-gray-800 border border-gray-700 p-4 hover:border-brand transition-colors">
94 <h3 class="text-sm font-semibold text-gray-100">Knowledge Base</h3>
95 <p class="mt-1 text-xs text-gray-500">Guides, runbooks, documentation</p>
96 </a>
97 {% endif %}
98 {% if perms.organization.view_organization %}
99 <a href="{% url 'organization:settings' %}" class="block rounded-lg bg-gray-800 border border-gray-700 p-4 hover:border-brand transition-colors">
100
--- templates/dashboard.html
+++ templates/dashboard.html
@@ -89,11 +89,11 @@
89 <p class="mt-1 text-xs text-gray-500">Organize members into teams</p>
90 </a>
91 {% endif %}
92 {% if perms.pages.view_page %}
93 <a href="{% url 'pages:list' %}" class="block rounded-lg bg-gray-800 border border-gray-700 p-4 hover:border-brand transition-colors">
94 <h3 class="text-sm font-semibold text-gray-100">Fossilrepo KB</h3>
95 <p class="mt-1 text-xs text-gray-500">Guides, runbooks, documentation</p>
96 </a>
97 {% endif %}
98 {% if perms.organization.view_organization %}
99 <a href="{% url 'organization:settings' %}" class="block rounded-lg bg-gray-800 border border-gray-700 p-4 hover:border-brand transition-colors">
100
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -41,8 +41,12 @@
4141
{% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
4242
</a>
4343
<a href="{% url 'fossil:repo_settings' slug=project.slug %}"
4444
class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'settings' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
4545
Settings
46
+ </a>
47
+ <a href="{% url 'fossil:explorer' slug=project.slug %}"
48
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'explorer' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
49
+ Explorer
4650
</a>
4751
{% endif %}
4852
</nav>
4953
5054
ADDED templates/fossil/explorer.html
5155
ADDED templates/fossil/explorer_query.html
5256
ADDED templates/fossil/partials/explorer_table.html
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -41,8 +41,12 @@
41 {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
42 </a>
43 <a href="{% url 'fossil:repo_settings' slug=project.slug %}"
44 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'settings' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
45 Settings
 
 
 
 
46 </a>
47 {% endif %}
48 </nav>
49
50 DDED templates/fossil/explorer.html
51 DDED templates/fossil/explorer_query.html
52 DDED templates/fossil/partials/explorer_table.html
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -41,8 +41,12 @@
41 {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
42 </a>
43 <a href="{% url 'fossil:repo_settings' slug=project.slug %}"
44 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'settings' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
45 Settings
46 </a>
47 <a href="{% url 'fossil:explorer' slug=project.slug %}"
48 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'explorer' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
49 Explorer
50 </a>
51 {% endif %}
52 </nav>
53
54 DDED templates/fossil/explorer.html
55 DDED templates/fossil/explorer_query.html
56 DDED templates/fossil/partials/explorer_table.html
--- a/templates/fossil/explorer.html
+++ b/templates/fossil/explorer.html
@@ -0,0 +1,192 @@
1
+{% extends "base.html" %}
2
+{% block title %}Explorer — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="flex items-center justify-between mb-6">
9
+ <div>
10
+ <h2 class="text-xl font-bold text-gray-100">SQLite Schema Explorer</h2>
11
+ <p class="text-sm text-gray-500 mt-1">
12
+ Explore the .fossil SQLite database structure. {{ tables|length }} table{{ tables|length|pluralize }} found.
13
+ </p>
14
+ </div>
15
+ <a href="{% url 'fossil:explorer_query' slug=project.slug %}"
16
+ class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md bg-brand text-white hover:bg-brand-hover">
17
+ Run Query
18
+ </a>
19
+</div>
20
+
21
+<!-- Schema Relationship Visualization -->
22
+<div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6" x-data="{ showViz: true }">
23
+ <div class="flex items-center justify-between mb-3">
24
+ <h3 class="text-sm font-medium text-gray-300">Schema Map</h3>
25
+ <button @click="showViz = !showViz" class="text-xs text-gray-500 hover:text-gray-300">
26
+ <span x-text="showViz ? 'Hide' : 'Show'"></span>
27
+ </button>
28
+ </div>
29
+ <div x-show="showViz" x-transition>
30
+ <!-- Category legend -->
31
+ <div class="flex flex-wrap gap-3 mb-4 text-xs">
32
+ <span class="flex items-center gap-1.5">
33
+ <span class="w-2.5 h-2.5 rounded-full bg-blue-500"></span>
34
+ <span class="text-gray-400">VCS Core</span>
35
+ </span>
36
+ <span class="flex items-center gap-1.5">
37
+ <span class="w-2.5 h-2.5 rounded-full bg-indigo-500"></span>
38
+ <span class="text-gray-400">Tags / Branches</span>
39
+ </span>
40
+ <span class="flex items-center gap-1.5">
41
+ <span class="w-2.5 h-2.5 rounded-full bg-green-500"></span>
42
+ <span class="text-gray-400">Tickets</span>
43
+ </span>
44
+ <span class="flex items-center gap-1.5">
45
+ <span class="w-2.5 h-2.5 rounded-full bg-purple-500"></span>
46
+ <span class="text-gray-400">Wiki</span>
47
+ </span>
48
+ <span class="flex items-center gap-1.5">
49
+ <span class="w-2.5 h-2.5 rounded-full bg-orange-500"></span>
50
+ <span class="text-gray-400">Forum</span>
51
+ </span>
52
+ <span class="flex items-center gap-1.5">
53
+ <span class="w-2.5 h-2.5 rounded-full bg-gray-500"></span>
54
+ <span class="text-gray-400">Other</span>
55
+ </span>
56
+ </div>
57
+
58
+ <!-- SVG relationship diagram -->
59
+ <div class="overflow-x-auto">
60
+ <svg id="schema-viz" class="w-full" style="min-height: 280px;"></svg>
61
+ </div>
62
+ </div>
63
+</div>
64
+
65
+<!-- Table grid -->
66
+<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 mb-6" x-data="{ selected: '' }">
67
+ {% for table in tables %}
68
+ <button
69
+ @click="selected = selected === '{{ table.name }}' ? '' : '{{ table.name }}'"
70
+ hx-get="{% url 'fossil:explorer_table' slug=project.slug table_name=table.name %}"
71
+ hx-target="#table-detail"
72
+ hx-swap="innerHTML"
73
+ class="text-left rounded-lg border p-3 transition-colors cursor-pointer
74
+ {% if table.category == 'blue' %}border-blue-800/50 hover:border-blue-600{% elif table.category == 'indigo' %}border-indigo-800/50 hover:border-indigo-600{% elif table.category == 'green' %}border-green-800/50 hover:border-green-600{% elif table.category == 'purple' %}border-purple-800/50 hover:border-purple-600{% elif table.category == 'orange' %}border-orange-800/50 hover:border-orange-600{% else %}border-gray-700 hover:border-gray-500{% endif %}"
75
+ :class="selected === '{{ table.name }}' ? 'bg-gray-700 ring-1 ring-brand' : 'bg-gray-800'">
76
+ <div class="flex items-center justify-between">
77
+ <span class="text-sm font-mono font-medium
78
+ {% if table.category == 'blue' %}text-blue-300{% elif table.category == 'indigo' %}text-indigo-300{% elif table.category == 'green' %}text-green-300{% elif table.category == 'purple' %}text-purple-300{% elif table.category == 'orange' %}text-orange-300{% else %}text-gray-300{% endif %}">
79
+ {{ table.name }}
80
+ </span>
81
+ <span class="text-xs text-gray-500 font-mono">{{ table.count }}</span>
82
+ </div>
83
+ </button>
84
+ {% endfor %}
85
+</div>
86
+
87
+<!-- Table detail panel (populated by HTMX) -->
88
+<div id="table-detail"></div>
89
+
90
+<script>
91
+(function() {
92
+ var tables = {{ tables_json|safe }};
93
+ var rels = {{ relationships_json|safe }};
94
+
95
+ var categoryColor = {
96
+ blue: '#3b82f6', indigo: '#6366f1', green: '#22c55e',
97
+ purple: '#a855f7', orange: '#f97316', gray: '#6b7280'
98
+ };
99
+
100
+ var svg = document.getElementById('schema-viz');
101
+ if (!svg || tables.length === 0) return;
102
+
103
+ // Layout: arrange tables in a grid, center the important ones.
104
+ var cols = Math.min(tables.length, 8);
105
+ var cellW = 130, cellH = 50, padX = 20, padY = 30;
106
+ var totalW = cols * cellW + (cols - 1) * padX;
107
+ var rows = Math.ceil(tables.length / cols);
108
+ var totalH = rows * cellH + (rows - 1) * padY;
109
+
110
+ svg.setAttribute('viewBox', '0 0 ' + (totalW + 40) + ' ' + (totalH + 40));
111
+ svg.style.minHeight = Math.max(200, totalH + 40) + 'px';
112
+
113
+ // Position each table.
114
+ var positions = {};
115
+ tables.forEach(function(t, i) {
116
+ var col = i % cols;
117
+ var row = Math.floor(i / cols);
118
+ var x = 20 + col * (cellW + padX);
119
+ var y = 20 + row * (cellH + padY);
120
+ positions[t.name] = { x: x + cellW / 2, y: y + cellH / 2 };
121
+
122
+ // Draw table box.
123
+ var color = categoryColor[t.category] || categoryColor.gray;
124
+ var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
125
+ rect.setAttribute('x', x);
126
+ rect.setAttribute('y', y);
127
+ rect.setAttribute('width', cellW);
128
+ rect.setAttribute('height', cellH);
129
+ rect.setAttribute('rx', 6);
130
+ rect.setAttribute('fill', '#1f2937');
131
+ rect.setAttribute('stroke', color);
132
+ rect.setAttribute('stroke-width', 1.5);
133
+ rect.setAttribute('stroke-opacity', 0.6);
134
+ svg.appendChild(rect);
135
+
136
+ // Table name.
137
+ var text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
138
+ text.setAttribute('x', x + cellW / 2);
139
+ text.setAttribute('y', y + 22);
140
+ text.setAttribute('text-anchor', 'middle');
141
+ text.setAttribute('fill', color);
142
+ text.setAttribute('font-size', 11);
143
+ text.setAttribute('font-family', 'ui-monospace, monospace');
144
+ text.setAttribute('font-weight', 600);
145
+ text.textContent = t.name;
146
+ svg.appendChild(text);
147
+
148
+ // Row count.
149
+ var cnt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
150
+ cnt.setAttribute('x', x + cellW / 2);
151
+ cnt.setAttribute('y', y + 38);
152
+ cnt.setAttribute('text-anchor', 'middle');
153
+ cnt.setAttribute('fill', '#6b7280');
154
+ cnt.setAttribute('font-size', 9);
155
+ cnt.setAttribute('font-family', 'ui-monospace, monospace');
156
+ cnt.textContent = t.count.toLocaleString() + ' rows';
157
+ svg.appendChild(cnt);
158
+ });
159
+
160
+ // Draw relationship lines.
161
+ rels.forEach(function(r) {
162
+ var from = positions[r[0]];
163
+ var to = positions[r[1]];
164
+ if (!from || !to) return;
165
+
166
+ var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
167
+ line.setAttribute('x1', from.x);
168
+ line.setAttribute('y1', from.y);
169
+ line.setAttribute('x2', to.x);
170
+ line.setAttribute('y2', to.y);
171
+ line.setAttribute('stroke', '#4b5563');
172
+ line.setAttribute('stroke-width', 1);
173
+ line.setAttribute('stroke-opacity', 0.5);
174
+ line.setAttribute('stroke-dasharray', '4,3');
175
+ svg.insertBefore(line, svg.firstChild);
176
+
177
+ // Midpoint label.
178
+ var mx = (from.x + to.x) / 2;
179
+ var my = (from.y + to.y) / 2;
180
+ var label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
181
+ label.setAttribute('x', mx);
182
+ label.setAttribute('y', my - 4);
183
+ label.setAttribute('text-anchor', 'middle');
184
+ label.setAttribute('fill', '#6b7280');
185
+ label.setAttribute('font-size', 8);
186
+ label.setAttribute('font-family', 'ui-monospace, monospace');
187
+ label.textContent = r[2];
188
+ svg.insertBefore(label, svg.firstChild);
189
+ });
190
+})();
191
+</script>
192
+{% endblock %}
--- a/templates/fossil/explorer.html
+++ b/templates/fossil/explorer.html
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/explorer.html
+++ b/templates/fossil/explorer.html
@@ -0,0 +1,192 @@
1 {% extends "base.html" %}
2 {% block title %}Explorer — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="flex items-center justify-between mb-6">
9 <div>
10 <h2 class="text-xl font-bold text-gray-100">SQLite Schema Explorer</h2>
11 <p class="text-sm text-gray-500 mt-1">
12 Explore the .fossil SQLite database structure. {{ tables|length }} table{{ tables|length|pluralize }} found.
13 </p>
14 </div>
15 <a href="{% url 'fossil:explorer_query' slug=project.slug %}"
16 class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md bg-brand text-white hover:bg-brand-hover">
17 Run Query
18 </a>
19 </div>
20
21 <!-- Schema Relationship Visualization -->
22 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6" x-data="{ showViz: true }">
23 <div class="flex items-center justify-between mb-3">
24 <h3 class="text-sm font-medium text-gray-300">Schema Map</h3>
25 <button @click="showViz = !showViz" class="text-xs text-gray-500 hover:text-gray-300">
26 <span x-text="showViz ? 'Hide' : 'Show'"></span>
27 </button>
28 </div>
29 <div x-show="showViz" x-transition>
30 <!-- Category legend -->
31 <div class="flex flex-wrap gap-3 mb-4 text-xs">
32 <span class="flex items-center gap-1.5">
33 <span class="w-2.5 h-2.5 rounded-full bg-blue-500"></span>
34 <span class="text-gray-400">VCS Core</span>
35 </span>
36 <span class="flex items-center gap-1.5">
37 <span class="w-2.5 h-2.5 rounded-full bg-indigo-500"></span>
38 <span class="text-gray-400">Tags / Branches</span>
39 </span>
40 <span class="flex items-center gap-1.5">
41 <span class="w-2.5 h-2.5 rounded-full bg-green-500"></span>
42 <span class="text-gray-400">Tickets</span>
43 </span>
44 <span class="flex items-center gap-1.5">
45 <span class="w-2.5 h-2.5 rounded-full bg-purple-500"></span>
46 <span class="text-gray-400">Wiki</span>
47 </span>
48 <span class="flex items-center gap-1.5">
49 <span class="w-2.5 h-2.5 rounded-full bg-orange-500"></span>
50 <span class="text-gray-400">Forum</span>
51 </span>
52 <span class="flex items-center gap-1.5">
53 <span class="w-2.5 h-2.5 rounded-full bg-gray-500"></span>
54 <span class="text-gray-400">Other</span>
55 </span>
56 </div>
57
58 <!-- SVG relationship diagram -->
59 <div class="overflow-x-auto">
60 <svg id="schema-viz" class="w-full" style="min-height: 280px;"></svg>
61 </div>
62 </div>
63 </div>
64
65 <!-- Table grid -->
66 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 mb-6" x-data="{ selected: '' }">
67 {% for table in tables %}
68 <button
69 @click="selected = selected === '{{ table.name }}' ? '' : '{{ table.name }}'"
70 hx-get="{% url 'fossil:explorer_table' slug=project.slug table_name=table.name %}"
71 hx-target="#table-detail"
72 hx-swap="innerHTML"
73 class="text-left rounded-lg border p-3 transition-colors cursor-pointer
74 {% if table.category == 'blue' %}border-blue-800/50 hover:border-blue-600{% elif table.category == 'indigo' %}border-indigo-800/50 hover:border-indigo-600{% elif table.category == 'green' %}border-green-800/50 hover:border-green-600{% elif table.category == 'purple' %}border-purple-800/50 hover:border-purple-600{% elif table.category == 'orange' %}border-orange-800/50 hover:border-orange-600{% else %}border-gray-700 hover:border-gray-500{% endif %}"
75 :class="selected === '{{ table.name }}' ? 'bg-gray-700 ring-1 ring-brand' : 'bg-gray-800'">
76 <div class="flex items-center justify-between">
77 <span class="text-sm font-mono font-medium
78 {% if table.category == 'blue' %}text-blue-300{% elif table.category == 'indigo' %}text-indigo-300{% elif table.category == 'green' %}text-green-300{% elif table.category == 'purple' %}text-purple-300{% elif table.category == 'orange' %}text-orange-300{% else %}text-gray-300{% endif %}">
79 {{ table.name }}
80 </span>
81 <span class="text-xs text-gray-500 font-mono">{{ table.count }}</span>
82 </div>
83 </button>
84 {% endfor %}
85 </div>
86
87 <!-- Table detail panel (populated by HTMX) -->
88 <div id="table-detail"></div>
89
90 <script>
91 (function() {
92 var tables = {{ tables_json|safe }};
93 var rels = {{ relationships_json|safe }};
94
95 var categoryColor = {
96 blue: '#3b82f6', indigo: '#6366f1', green: '#22c55e',
97 purple: '#a855f7', orange: '#f97316', gray: '#6b7280'
98 };
99
100 var svg = document.getElementById('schema-viz');
101 if (!svg || tables.length === 0) return;
102
103 // Layout: arrange tables in a grid, center the important ones.
104 var cols = Math.min(tables.length, 8);
105 var cellW = 130, cellH = 50, padX = 20, padY = 30;
106 var totalW = cols * cellW + (cols - 1) * padX;
107 var rows = Math.ceil(tables.length / cols);
108 var totalH = rows * cellH + (rows - 1) * padY;
109
110 svg.setAttribute('viewBox', '0 0 ' + (totalW + 40) + ' ' + (totalH + 40));
111 svg.style.minHeight = Math.max(200, totalH + 40) + 'px';
112
113 // Position each table.
114 var positions = {};
115 tables.forEach(function(t, i) {
116 var col = i % cols;
117 var row = Math.floor(i / cols);
118 var x = 20 + col * (cellW + padX);
119 var y = 20 + row * (cellH + padY);
120 positions[t.name] = { x: x + cellW / 2, y: y + cellH / 2 };
121
122 // Draw table box.
123 var color = categoryColor[t.category] || categoryColor.gray;
124 var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
125 rect.setAttribute('x', x);
126 rect.setAttribute('y', y);
127 rect.setAttribute('width', cellW);
128 rect.setAttribute('height', cellH);
129 rect.setAttribute('rx', 6);
130 rect.setAttribute('fill', '#1f2937');
131 rect.setAttribute('stroke', color);
132 rect.setAttribute('stroke-width', 1.5);
133 rect.setAttribute('stroke-opacity', 0.6);
134 svg.appendChild(rect);
135
136 // Table name.
137 var text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
138 text.setAttribute('x', x + cellW / 2);
139 text.setAttribute('y', y + 22);
140 text.setAttribute('text-anchor', 'middle');
141 text.setAttribute('fill', color);
142 text.setAttribute('font-size', 11);
143 text.setAttribute('font-family', 'ui-monospace, monospace');
144 text.setAttribute('font-weight', 600);
145 text.textContent = t.name;
146 svg.appendChild(text);
147
148 // Row count.
149 var cnt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
150 cnt.setAttribute('x', x + cellW / 2);
151 cnt.setAttribute('y', y + 38);
152 cnt.setAttribute('text-anchor', 'middle');
153 cnt.setAttribute('fill', '#6b7280');
154 cnt.setAttribute('font-size', 9);
155 cnt.setAttribute('font-family', 'ui-monospace, monospace');
156 cnt.textContent = t.count.toLocaleString() + ' rows';
157 svg.appendChild(cnt);
158 });
159
160 // Draw relationship lines.
161 rels.forEach(function(r) {
162 var from = positions[r[0]];
163 var to = positions[r[1]];
164 if (!from || !to) return;
165
166 var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
167 line.setAttribute('x1', from.x);
168 line.setAttribute('y1', from.y);
169 line.setAttribute('x2', to.x);
170 line.setAttribute('y2', to.y);
171 line.setAttribute('stroke', '#4b5563');
172 line.setAttribute('stroke-width', 1);
173 line.setAttribute('stroke-opacity', 0.5);
174 line.setAttribute('stroke-dasharray', '4,3');
175 svg.insertBefore(line, svg.firstChild);
176
177 // Midpoint label.
178 var mx = (from.x + to.x) / 2;
179 var my = (from.y + to.y) / 2;
180 var label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
181 label.setAttribute('x', mx);
182 label.setAttribute('y', my - 4);
183 label.setAttribute('text-anchor', 'middle');
184 label.setAttribute('fill', '#6b7280');
185 label.setAttribute('font-size', 8);
186 label.setAttribute('font-family', 'ui-monospace, monospace');
187 label.textContent = r[2];
188 svg.insertBefore(label, svg.firstChild);
189 });
190 })();
191 </script>
192 {% endblock %}
--- a/templates/fossil/explorer_query.html
+++ b/templates/fossil/explorer_query.html
@@ -0,0 +1,103 @@
1
+{% extends "base.html" %}
2
+{% block title %}Query Runner — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="mb-4">
9
+ <a href="{% url 'fossil:explorer' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Explorer</a>
10
+</div>
11
+
12
+<div class="flex items-center justify-between mb-4">
13
+ <div>
14
+ <h2 class="text-xl font-bold text-gray-100">SQL Query Runner</h2>
15
+ <p class="text-sm text-gray-500 mt-1">
16
+ Execute read-only SELECT queries against the Fossil SQLite database. Results capped at 500 rows.
17
+ </p>
18
+ </div>
19
+</div>
20
+
21
+<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
22
+ <!-- Query input + results (3/4 width) -->
23
+ <div class="lg:col-span-3">
24
+ <form method="get" action="{% url 'fossil:explorer_query' slug=project.slug %}">
25
+ <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden mb-4">
26
+ <div class="px-4 py-2 border-b border-gray-700">
27
+ <label for="sql-input" class="text-xs font-medium text-gray-400 uppercase">SQL Query</label>
28
+ </div>
29
+ <textarea
30
+ id="sql-input"
31
+ name="sql"
32
+ rows="5"
33
+ class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 border-0 focus:ring-0 resize-y"
34
+ placeholder="SELECT * FROM blob LIMIT 10"
35
+ spellcheck="false">{{ sql }}</textarea>
36
+ <div class="px-4 py-2 border-t border-gray-700 flex items-center justify-between">
37
+ <span class="text-xs text-gray-600">Only SELECT queries are allowed. No INSERT, UPDATE, DELETE, DROP, etc.</span>
38
+ <button type="submit" class="inline-flex items-center px-4 py-1.5 text-sm font-medium rounded-md bg-brand text-white hover:bg-brand-hover">
39
+ Run
40
+ </button>
41
+ </div>
42
+ </div>
43
+ </form>
44
+
45
+ {% if error %}
46
+ <div class="rounded-lg bg-red-900/30 border border-red-700 p-4 mb-4">
47
+ <p class="text-sm text-red-300 font-mono">{{ error }}</p>
48
+ </div>
49
+ {% endif %}
50
+
51
+ {% if columns %}
52
+ <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-x-auto">
53
+ <table class="min-w-full divide-y divide-gray-700">
54
+ <thead>
55
+ <tr>
56
+ {% for col in columns %}
57
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider whitespace-nowrap">{{ col }}</th>
58
+ {% endfor %}
59
+ </tr>
60
+ </thead>
61
+ <tbody class="divide-y divide-gray-700 font-mono text-xs">
62
+ {% for row in results %}
63
+ <tr class="hover:bg-gray-700/50">
64
+ {% for cell in row %}
65
+ <td class="px-4 py-2 text-gray-300 whitespace-nowrap max-w-xs truncate">{{ cell|default:"NULL" }}</td>
66
+ {% endfor %}
67
+ </tr>
68
+ {% endfor %}
69
+ </tbody>
70
+ </table>
71
+ </div>
72
+ <div class="mt-2 text-xs text-gray-500">
73
+ {{ results|length }} row{{ results|length|pluralize }} returned{% if results|length >= 500 %} (limited to 500){% endif %}
74
+ </div>
75
+ {% elif sql and not error %}
76
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
77
+ <p class="text-sm text-gray-500">No results.</p>
78
+ </div>
79
+ {% endif %}
80
+ </div>
81
+
82
+ <!-- Table list sidebar (1/4 width) -->
83
+ <div class="lg:col-span-1">
84
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-3 sticky top-6">
85
+ <h3 class="text-xs font-medium text-gray-400 uppercase mb-2">Available Tables</h3>
86
+ {% if table_names %}
87
+ <div class="space-y-0.5 max-h-96 overflow-y-auto">
88
+ {% for name in table_names %}
89
+ <button
90
+ type="button"
91
+ onclick="document.getElementById('sql-input').value = 'SELECT * FROM {{ name }} LIMIT 25'; document.getElementById('sql-input').focus();"
92
+ class="block w-full text-left px-2 py-1 text-xs font-mono text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded">
93
+ {{ name }}
94
+ </button>
95
+ {% endfor %}
96
+ </div>
97
+ {% else %}
98
+ <p class="text-xs text-gray-600">Run a query to see tables, or go to the <a href="{% url 'fossil:explorer' slug=project.slug %}" class="text-brand-light hover:text-brand">Explorer</a>.</p>
99
+ {% endif %}
100
+ </div>
101
+ </div>
102
+</div>
103
+{% endblock %}
--- a/templates/fossil/explorer_query.html
+++ b/templates/fossil/explorer_query.html
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/explorer_query.html
+++ b/templates/fossil/explorer_query.html
@@ -0,0 +1,103 @@
1 {% extends "base.html" %}
2 {% block title %}Query Runner — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="mb-4">
9 <a href="{% url 'fossil:explorer' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Explorer</a>
10 </div>
11
12 <div class="flex items-center justify-between mb-4">
13 <div>
14 <h2 class="text-xl font-bold text-gray-100">SQL Query Runner</h2>
15 <p class="text-sm text-gray-500 mt-1">
16 Execute read-only SELECT queries against the Fossil SQLite database. Results capped at 500 rows.
17 </p>
18 </div>
19 </div>
20
21 <div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
22 <!-- Query input + results (3/4 width) -->
23 <div class="lg:col-span-3">
24 <form method="get" action="{% url 'fossil:explorer_query' slug=project.slug %}">
25 <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden mb-4">
26 <div class="px-4 py-2 border-b border-gray-700">
27 <label for="sql-input" class="text-xs font-medium text-gray-400 uppercase">SQL Query</label>
28 </div>
29 <textarea
30 id="sql-input"
31 name="sql"
32 rows="5"
33 class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 border-0 focus:ring-0 resize-y"
34 placeholder="SELECT * FROM blob LIMIT 10"
35 spellcheck="false">{{ sql }}</textarea>
36 <div class="px-4 py-2 border-t border-gray-700 flex items-center justify-between">
37 <span class="text-xs text-gray-600">Only SELECT queries are allowed. No INSERT, UPDATE, DELETE, DROP, etc.</span>
38 <button type="submit" class="inline-flex items-center px-4 py-1.5 text-sm font-medium rounded-md bg-brand text-white hover:bg-brand-hover">
39 Run
40 </button>
41 </div>
42 </div>
43 </form>
44
45 {% if error %}
46 <div class="rounded-lg bg-red-900/30 border border-red-700 p-4 mb-4">
47 <p class="text-sm text-red-300 font-mono">{{ error }}</p>
48 </div>
49 {% endif %}
50
51 {% if columns %}
52 <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-x-auto">
53 <table class="min-w-full divide-y divide-gray-700">
54 <thead>
55 <tr>
56 {% for col in columns %}
57 <th class="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider whitespace-nowrap">{{ col }}</th>
58 {% endfor %}
59 </tr>
60 </thead>
61 <tbody class="divide-y divide-gray-700 font-mono text-xs">
62 {% for row in results %}
63 <tr class="hover:bg-gray-700/50">
64 {% for cell in row %}
65 <td class="px-4 py-2 text-gray-300 whitespace-nowrap max-w-xs truncate">{{ cell|default:"NULL" }}</td>
66 {% endfor %}
67 </tr>
68 {% endfor %}
69 </tbody>
70 </table>
71 </div>
72 <div class="mt-2 text-xs text-gray-500">
73 {{ results|length }} row{{ results|length|pluralize }} returned{% if results|length >= 500 %} (limited to 500){% endif %}
74 </div>
75 {% elif sql and not error %}
76 <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
77 <p class="text-sm text-gray-500">No results.</p>
78 </div>
79 {% endif %}
80 </div>
81
82 <!-- Table list sidebar (1/4 width) -->
83 <div class="lg:col-span-1">
84 <div class="rounded-lg bg-gray-800 border border-gray-700 p-3 sticky top-6">
85 <h3 class="text-xs font-medium text-gray-400 uppercase mb-2">Available Tables</h3>
86 {% if table_names %}
87 <div class="space-y-0.5 max-h-96 overflow-y-auto">
88 {% for name in table_names %}
89 <button
90 type="button"
91 onclick="document.getElementById('sql-input').value = 'SELECT * FROM {{ name }} LIMIT 25'; document.getElementById('sql-input').focus();"
92 class="block w-full text-left px-2 py-1 text-xs font-mono text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded">
93 {{ name }}
94 </button>
95 {% endfor %}
96 </div>
97 {% else %}
98 <p class="text-xs text-gray-600">Run a query to see tables, or go to the <a href="{% url 'fossil:explorer' slug=project.slug %}" class="text-brand-light hover:text-brand">Explorer</a>.</p>
99 {% endif %}
100 </div>
101 </div>
102 </div>
103 {% endblock %}
--- a/templates/fossil/partials/explorer_table.html
+++ b/templates/fossil/partials/explorer_table.html
@@ -0,0 +1,55 @@
1
+<div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden">
2
+ <!-- Header -->
3
+ <div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
4
+ <div>
5
+ <h3 class="text-base font-mono font-semibold text-gray-100">{{ table_name }}</h3>
6
+ <p class="text-xs text-gray-500 mt-0.5">{{ total|default:"0" }} row{{ total|pluralize }} &middot; {{ columns|length }} column{{ columns|length|pluralize }}</p>
7
+ </div>
8
+ <a href="{% url 'fossil:explorer_query' slug=project.slug %}?sql=SELECT+*+FROM+{{ table_name }}+LIMIT+25"
9
+ class="text-xs text-brand-light hover:text-brand">
10
+ Query this table
11
+ </a>
12
+ </div>
13
+
14
+ <!-- Column definitions -->
15
+ <div class="px-4 py-3 border-b border-gray-700">
16
+ <h4 class="text-xs font-medium text-gray-400 uppercase mb-2">Columns</h4>
17
+ <div class="overflow-x-auto">
18
+ <table class="min-w-full text-xs">
19
+ <thead>
20
+ <tr class="text-gray-500">
21
+ <th class="text-left pr-4 pb-1 font-medium">#</th>
22
+ <th class="text-left pr-4 pb-1 50ext-left pr-4 pb-1 font-medium">Type</th>
23
+ <th class="text-left pr-4 pb-1 font-medium">Not Null</th>
24
+ <th class="text-left pb-1 font-medium">PK</th>
25
+ </tr>
26
+ </thead>
27
+ <tbody class="font-mono">
28
+ {% for col in columns %}
29
+ <tr class="{% if col.pk %}text-yellow-400{% else %}text-gray-300{% endif %}">
30
+ <td class="pr-4 py-0.5 text-gray-600">{{ col.cid }}</td>
31
+ <td class="pr-4 py-0.5 font-medium">{{ col.name }}</td>
32
+ <td class="pr-4 py-0.5 text-gray-400">{{ col.type }}</td>
33
+ <td class="pr-4 py-0.5 text-gray-500">{% if col.notnull %}YES{% else %}{% endif %}</td>
34
+ <td class="py-0.5">{% if col.pk %}<span class="text-yellow-500 font-bold">PK</span>{% endif %}</td>
35
+ </tr>
36
+ {% endfor %}
37
+ </tbody>
38
+ </table>
39
+ </div>
40
+ </div>
41
+
42
+ <!-- Sample data -->
43
+ <div class="px-4 py-3">
44
+ <h4 class="text-xs font-medium text-gray-400 uppercase mb-2">
45
+ Data
46
+ {% if total > 0 %}
47
+ <span class="text-gray-600 font-normal normal-case">
48
+ (rows {{ page|add:"-1"|default:"0" }}{% widthratio per_page 1 1 %} - showing page {{ page }})
49
+ </span>
50
+ {% endif %}
51
+ </h4>
52
+
53
+ {% if rows %}
54
+ <div class="overflow-x-auto">
55
+ <table class="min-w-full divide-y divide-gray-700 text-xs">
--- a/templates/fossil/partials/explorer_table.html
+++ b/templates/fossil/partials/explorer_table.html
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/partials/explorer_table.html
+++ b/templates/fossil/partials/explorer_table.html
@@ -0,0 +1,55 @@
1 <div class="rounded-lg bg-gray-800 border border-gray-700 overflow-hidden">
2 <!-- Header -->
3 <div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
4 <div>
5 <h3 class="text-base font-mono font-semibold text-gray-100">{{ table_name }}</h3>
6 <p class="text-xs text-gray-500 mt-0.5">{{ total|default:"0" }} row{{ total|pluralize }} &middot; {{ columns|length }} column{{ columns|length|pluralize }}</p>
7 </div>
8 <a href="{% url 'fossil:explorer_query' slug=project.slug %}?sql=SELECT+*+FROM+{{ table_name }}+LIMIT+25"
9 class="text-xs text-brand-light hover:text-brand">
10 Query this table
11 </a>
12 </div>
13
14 <!-- Column definitions -->
15 <div class="px-4 py-3 border-b border-gray-700">
16 <h4 class="text-xs font-medium text-gray-400 uppercase mb-2">Columns</h4>
17 <div class="overflow-x-auto">
18 <table class="min-w-full text-xs">
19 <thead>
20 <tr class="text-gray-500">
21 <th class="text-left pr-4 pb-1 font-medium">#</th>
22 <th class="text-left pr-4 pb-1 50ext-left pr-4 pb-1 font-medium">Type</th>
23 <th class="text-left pr-4 pb-1 font-medium">Not Null</th>
24 <th class="text-left pb-1 font-medium">PK</th>
25 </tr>
26 </thead>
27 <tbody class="font-mono">
28 {% for col in columns %}
29 <tr class="{% if col.pk %}text-yellow-400{% else %}text-gray-300{% endif %}">
30 <td class="pr-4 py-0.5 text-gray-600">{{ col.cid }}</td>
31 <td class="pr-4 py-0.5 font-medium">{{ col.name }}</td>
32 <td class="pr-4 py-0.5 text-gray-400">{{ col.type }}</td>
33 <td class="pr-4 py-0.5 text-gray-500">{% if col.notnull %}YES{% else %}{% endif %}</td>
34 <td class="py-0.5">{% if col.pk %}<span class="text-yellow-500 font-bold">PK</span>{% endif %}</td>
35 </tr>
36 {% endfor %}
37 </tbody>
38 </table>
39 </div>
40 </div>
41
42 <!-- Sample data -->
43 <div class="px-4 py-3">
44 <h4 class="text-xs font-medium text-gray-400 uppercase mb-2">
45 Data
46 {% if total > 0 %}
47 <span class="text-gray-600 font-normal normal-case">
48 (rows {{ page|add:"-1"|default:"0" }}{% widthratio per_page 1 1 %} - showing page {{ page }})
49 </span>
50 {% endif %}
51 </h4>
52
53 {% if rows %}
54 <div class="overflow-x-auto">
55 <table class="min-w-full divide-y divide-gray-700 text-xs">
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -76,21 +76,21 @@
7676
{% endif %}
7777
</div>
7878
</div>
7979
{% endif %}
8080
81
- <!-- Knowledge Base section -->
81
+ <!-- Fossilrepo KB section -->
8282
{% if perms.pages.view_page %}
8383
<div>
8484
<button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)"
8585
class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/kb/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
86
- :title="collapsed ? 'Knowledge Base' : ''">
86
+ :title="collapsed ? 'Fossilrepo KB' : ''">
8787
<span class="flex items-center gap-2">
8888
<svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
8989
<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" />
9090
</svg>
91
- <span x-show="!collapsed" class="truncate">Knowledge Base</span>
91
+ <span x-show="!collapsed" class="truncate">Fossilrepo KB</span>
9292
</span>
9393
<svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="docsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
9494
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
9595
</svg>
9696
</button>
9797
9898
ADDED templates/organization/role_confirm_delete.html
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -76,21 +76,21 @@
76 {% endif %}
77 </div>
78 </div>
79 {% endif %}
80
81 <!-- Knowledge Base section -->
82 {% if perms.pages.view_page %}
83 <div>
84 <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)"
85 class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/kb/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
86 :title="collapsed ? 'Knowledge Base' : ''">
87 <span class="flex items-center gap-2">
88 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
89 <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" />
90 </svg>
91 <span x-show="!collapsed" class="truncate">Knowledge Base</span>
92 </span>
93 <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="docsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
94 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
95 </svg>
96 </button>
97
98 DDED templates/organization/role_confirm_delete.html
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -76,21 +76,21 @@
76 {% endif %}
77 </div>
78 </div>
79 {% endif %}
80
81 <!-- Fossilrepo KB section -->
82 {% if perms.pages.view_page %}
83 <div>
84 <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)"
85 class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/kb/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
86 :title="collapsed ? 'Fossilrepo KB' : ''">
87 <span class="flex items-center gap-2">
88 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
89 <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" />
90 </svg>
91 <span x-show="!collapsed" class="truncate">Fossilrepo KB</span>
92 </span>
93 <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="docsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
94 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
95 </svg>
96 </button>
97
98 DDED templates/organization/role_confirm_delete.html
--- a/templates/organization/role_confirm_delete.html
+++ b/templates/organization/role_confirm_delete.html
@@ -0,0 +1,49 @@
1
+{% extends "base.html" %}
2
+{% block title %}Delete {{ role.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:role_detail' slug=role.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ role.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 Role</h2>
12
+
13
+ {% if active_members.exists %}
14
+ <div class="mt-4 rounded-md bg-yellow-900/50 border border-yellow-700 p-4">
15
+ <p class="text-sm text-yellow-300">
16
+ This role has <strong>{{ active_members.count }}</strong> active member{{ active_members.count|pluralize }}.
17
+ You must reassign them to another role before deleting.
18
+ </p>
19
+ <ul class="mt-2 text-sm text-yellow-300 list-disc list-inside">
20
+ {% for membership in active_members %}
21
+ <li>{{ membership.member.username }}</li>
22
+ {% endfor %}
23
+ </ul>
24
+ </div>
25
+ <div class="mt-6 flex justify-end">
26
+ <a href="{% url 'organization:role_detail' slug=role.slug %}"
27
+ 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">
28
+ Go Back
29
+ </a>
30
+ </div>
31
+ {% else %}
32
+ <p class="mt-2 text-sm text-gray-400">
33
+ Are you sure you want to delete <strong class="text-gray-100">{{ role.name }}</strong>? This action uses soft delete -- the record will be marked as deleted but can be recovered.
34
+ </p>
35
+ <form method="post" class="mt-6 flex justify-end gap-3">
36
+ {% csrf_token %}
37
+ <a href="{% url 'organization:role_detail' slug=role.slug %}"
38
+ 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">
39
+ Cancel
40
+ </a>
41
+ <button type="submit"
42
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
43
+ Delete
44
+ </button>
45
+ </form>
46
+ {% endif %}
47
+ </div>
48
+</div>
49
+{% endblock %}
--- a/templates/organization/role_confirm_delete.html
+++ b/templates/organization/role_confirm_delete.html
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/role_confirm_delete.html
+++ b/templates/organization/role_confirm_delete.html
@@ -0,0 +1,49 @@
1 {% extends "base.html" %}
2 {% block title %}Delete {{ role.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:role_detail' slug=role.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ role.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 Role</h2>
12
13 {% if active_members.exists %}
14 <div class="mt-4 rounded-md bg-yellow-900/50 border border-yellow-700 p-4">
15 <p class="text-sm text-yellow-300">
16 This role has <strong>{{ active_members.count }}</strong> active member{{ active_members.count|pluralize }}.
17 You must reassign them to another role before deleting.
18 </p>
19 <ul class="mt-2 text-sm text-yellow-300 list-disc list-inside">
20 {% for membership in active_members %}
21 <li>{{ membership.member.username }}</li>
22 {% endfor %}
23 </ul>
24 </div>
25 <div class="mt-6 flex justify-end">
26 <a href="{% url 'organization:role_detail' slug=role.slug %}"
27 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">
28 Go Back
29 </a>
30 </div>
31 {% else %}
32 <p class="mt-2 text-sm text-gray-400">
33 Are you sure you want to delete <strong class="text-gray-100">{{ role.name }}</strong>? This action uses soft delete -- the record will be marked as deleted but can be recovered.
34 </p>
35 <form method="post" class="mt-6 flex justify-end gap-3">
36 {% csrf_token %}
37 <a href="{% url 'organization:role_detail' slug=role.slug %}"
38 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">
39 Cancel
40 </a>
41 <button type="submit"
42 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
43 Delete
44 </button>
45 </form>
46 {% endif %}
47 </div>
48 </div>
49 {% endblock %}
--- templates/organization/role_detail.html
+++ templates/organization/role_detail.html
@@ -10,13 +10,23 @@
1010
<div class="px-6 py-5 sm:flex sm:items-center sm:justify-between">
1111
<div>
1212
<h1 class="text-2xl font-bold text-gray-100">{{ role.name }}</h1>
1313
<p class="mt-1 text-sm text-gray-400">{{ role.description|default:"No description." }}</p>
1414
</div>
15
- <div class="mt-4 flex items-center gap-2 sm:mt-0">
15
+ <div class="mt-4 flex items-center gap-3 sm:mt-0">
1616
{% if role.is_default %}
1717
<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
+ {% if perms.organization.change_organization or user.is_superuser %}
20
+ <a href="{% url 'organization:role_edit' slug=role.slug %}"
21
+ 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">
22
+ Edit
23
+ </a>
24
+ <a href="{% url 'organization:role_delete' slug=role.slug %}"
25
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
26
+ Delete
27
+ </a>
1828
{% endif %}
1929
</div>
2030
</div>
2131
2232
<div class="border-t border-gray-700 px-6 py-5">
2333
2434
ADDED templates/organization/role_form.html
--- templates/organization/role_detail.html
+++ templates/organization/role_detail.html
@@ -10,13 +10,23 @@
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
24 DDED templates/organization/role_form.html
--- templates/organization/role_detail.html
+++ templates/organization/role_detail.html
@@ -10,13 +10,23 @@
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-3 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 {% if perms.organization.change_organization or user.is_superuser %}
20 <a href="{% url 'organization:role_edit' slug=role.slug %}"
21 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">
22 Edit
23 </a>
24 <a href="{% url 'organization:role_delete' slug=role.slug %}"
25 class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
26 Delete
27 </a>
28 {% endif %}
29 </div>
30 </div>
31
32 <div class="border-t border-gray-700 px-6 py-5">
33
34 DDED templates/organization/role_form.html
--- a/templates/organization/role_form.html
+++ b/templates/organization/role_form.html
@@ -0,0 +1,98 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — 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="mx-auto max-w-3xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
+
12
+ <form method="post" class="space-y-6">
13
+ {% csrf_token %}
14
+
15
+ <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700 space-y-6">
16
+ <div>
17
+ <label for="{{ form.name.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ Name <span class="text-red-400">*</span>
19
+ </label>
20
+ <div class="mt-1">{{ form.name }}</div>
21
+ {% if form.name.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ form.name.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+
26
+ <div>
27
+ <label for="{{ form.description.id_for_label }}" class="block text-sm font-medium text-gray-300">
28
+ Description
29
+ </label>
30
+ <div class="mt-1">{{ form.description }}</div>
31
+ {% if form.description.errors %}
32
+ <p class="mt-1 text-sm text-red-400">{{ form.description.errors.0 }}</p>
33
+ {% endif %}
34
+ </div>
35
+
36
+ <div>
37
+ <div class="flex items-center gap-3">
38
+ {{ form.is_default }}
39
+ <label for="{{ form.is_default.id_for_label }}" class="text-sm font-medium text-gray-300">
40
+ Default role
41
+ </label>
42
+ </div>
43
+ <p class="mt-1 text-sm text-gray-500">Assigned to new users automatically.</p>
44
+ </div>
45
+ </div>
46
+
47
+ <div>
48
+ <h2 class="text-lg font-semibold text-gray-100 mb-4">Permissions</h2>
49
+ {% if form.permissions.errors %}
50
+ <p class="mb-2 text-sm text-red-400">{{ form.permissions.errors.0 }}</p>
51
+ {% endif %}
52
+
53
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2" x-data="{ selectAll(groupId) {
54
+ document.querySelectorAll('#perm-group-' + groupId + ' input[type=checkbox]').forEach(cb => cb.checked = true);
55
+ }, deselectAll(groupId) {
56
+ document.querySelectorAll('#perm-group-' + groupId + ' input[type=checkbox]').forEach(cb => cb.checked = false);
57
+ }}">
58
+ {% for category, perms in form.grouped_permissions.items %}
59
+ <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"
60
+ id="perm-group-{{ forloop.counter }}">
61
+ <div class="bg-gray-900 px-4 py-3 flex items-center justify-between">
62
+ <h3 class="text-sm font-semibold text-gray-200">{{ category }}</h3>
63
+ <div class="flex gap-2">
64
+ <button type="button" @click="selectAll({{ forloop.counter }})"
65
+ class="text-xs text-brand-light hover:text-brand">All</button>
66
+ <span class="text-gray-600">|</span>
67
+ <button type="button" @click="deselectAll({{ forloop.counter }})"
68
+ class="text-xs text-gray-400 hover:text-gray-200">None</button>
69
+ </div>
70
+ </div>
71
+ <div class="px-4 py-3 grid grid-cols-1 gap-2">
72
+ {% for item in perms %}
73
+ <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer hover:text-gray-100">
74
+ <input type="checkbox" name="permissions" value="{{ item.perm.pk }}"
75
+ {% if item.checked %}checked{% endif %}
76
+ class="rounded border-gray-600 text-brand focus:ring-brand bg-gray-700">
77
+ <span>{{ item.perm.name }}</span>
78
+ </label>
79
+ {% endfor %}
80
+ </div>
81
+ </div>
82
+ {% endfor %}
83
+ </div>
84
+ </div>
85
+
86
+ <div class="flex justify-end gap-3 pt-4">
87
+ <a href="{% url 'organization:role_list' %}"
88
+ 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">
89
+ Cancel
90
+ </a>
91
+ <button type="submit"
92
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
93
+ {% if role %}Update{% else %}Create{% endif %}
94
+ </button>
95
+ </div>
96
+ </form>
97
+</div>
98
+{% endblock %}
--- a/templates/organization/role_form.html
+++ b/templates/organization/role_form.html
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/role_form.html
+++ b/templates/organization/role_form.html
@@ -0,0 +1,98 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — 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="mx-auto max-w-3xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12 <form method="post" class="space-y-6">
13 {% csrf_token %}
14
15 <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700 space-y-6">
16 <div>
17 <label for="{{ form.name.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 Name <span class="text-red-400">*</span>
19 </label>
20 <div class="mt-1">{{ form.name }}</div>
21 {% if form.name.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ form.name.errors.0 }}</p>
23 {% endif %}
24 </div>
25
26 <div>
27 <label for="{{ form.description.id_for_label }}" class="block text-sm font-medium text-gray-300">
28 Description
29 </label>
30 <div class="mt-1">{{ form.description }}</div>
31 {% if form.description.errors %}
32 <p class="mt-1 text-sm text-red-400">{{ form.description.errors.0 }}</p>
33 {% endif %}
34 </div>
35
36 <div>
37 <div class="flex items-center gap-3">
38 {{ form.is_default }}
39 <label for="{{ form.is_default.id_for_label }}" class="text-sm font-medium text-gray-300">
40 Default role
41 </label>
42 </div>
43 <p class="mt-1 text-sm text-gray-500">Assigned to new users automatically.</p>
44 </div>
45 </div>
46
47 <div>
48 <h2 class="text-lg font-semibold text-gray-100 mb-4">Permissions</h2>
49 {% if form.permissions.errors %}
50 <p class="mb-2 text-sm text-red-400">{{ form.permissions.errors.0 }}</p>
51 {% endif %}
52
53 <div class="grid grid-cols-1 gap-4 sm:grid-cols-2" x-data="{ selectAll(groupId) {
54 document.querySelectorAll('#perm-group-' + groupId + ' input[type=checkbox]').forEach(cb => cb.checked = true);
55 }, deselectAll(groupId) {
56 document.querySelectorAll('#perm-group-' + groupId + ' input[type=checkbox]').forEach(cb => cb.checked = false);
57 }}">
58 {% for category, perms in form.grouped_permissions.items %}
59 <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"
60 id="perm-group-{{ forloop.counter }}">
61 <div class="bg-gray-900 px-4 py-3 flex items-center justify-between">
62 <h3 class="text-sm font-semibold text-gray-200">{{ category }}</h3>
63 <div class="flex gap-2">
64 <button type="button" @click="selectAll({{ forloop.counter }})"
65 class="text-xs text-brand-light hover:text-brand">All</button>
66 <span class="text-gray-600">|</span>
67 <button type="button" @click="deselectAll({{ forloop.counter }})"
68 class="text-xs text-gray-400 hover:text-gray-200">None</button>
69 </div>
70 </div>
71 <div class="px-4 py-3 grid grid-cols-1 gap-2">
72 {% for item in perms %}
73 <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer hover:text-gray-100">
74 <input type="checkbox" name="permissions" value="{{ item.perm.pk }}"
75 {% if item.checked %}checked{% endif %}
76 class="rounded border-gray-600 text-brand focus:ring-brand bg-gray-700">
77 <span>{{ item.perm.name }}</span>
78 </label>
79 {% endfor %}
80 </div>
81 </div>
82 {% endfor %}
83 </div>
84 </div>
85
86 <div class="flex justify-end gap-3 pt-4">
87 <a href="{% url 'organization:role_list' %}"
88 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">
89 Cancel
90 </a>
91 <button type="submit"
92 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
93 {% if role %}Update{% else %}Create{% endif %}
94 </button>
95 </div>
96 </form>
97 </div>
98 {% endblock %}
--- templates/organization/role_list.html
+++ templates/organization/role_list.html
@@ -6,21 +6,29 @@
66
<a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
77
</div>
88
99
<div class="md:flex md:items-center md:justify-between mb-6">
1010
<h1 class="text-2xl font-bold text-gray-100">Roles</h1>
11
- {% if not roles %}
12
- {% if perms.organization.change_organization or user.is_superuser %}
13
- <form method="post" action="{% url 'organization:role_initialize' %}">
14
- {% csrf_token %}
15
- <button type="submit"
16
- 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">
17
- Initialize Roles
18
- </button>
19
- </form>
20
- {% endif %}
21
- {% endif %}
11
+ <div class="mt-4 md:mt-0 flex gap-3">
12
+ {% if not roles %}
13
+ {% if perms.organization.change_organization or user.is_superuser %}
14
+ <form method="post" action="{% url 'organization:role_initialize' %}">
15
+ {% csrf_token %}
16
+ <button type="submit"
17
+ class="inline-flex items-center 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">
18
+ Initialize Roles
19
+ </button>
20
+ </form>
21
+ {% endif %}
22
+ {% endif %}
23
+ {% if perms.organization.change_organization or user.is_superuser %}
24
+ <a href="{% url 'organization:role_create' %}"
25
+ class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
26
+ Create Role
27
+ </a>
28
+ {% endif %}
29
+ </div>
2230
</div>
2331
2432
{% if roles %}
2533
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
2634
{% for role in roles %}
2735
--- templates/organization/role_list.html
+++ templates/organization/role_list.html
@@ -6,21 +6,29 @@
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 text-gray-100">Roles</h1>
11 {% if not roles %}
12 {% if perms.organization.change_organization or user.is_superuser %}
13 <form method="post" action="{% url 'organization:role_initialize' %}">
14 {% csrf_token %}
15 <button type="submit"
16 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">
17 Initialize Roles
18 </button>
19 </form>
20 {% endif %}
21 {% endif %}
 
 
 
 
 
 
 
 
22 </div>
23
24 {% if roles %}
25 <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
26 {% for role in roles %}
27
--- templates/organization/role_list.html
+++ templates/organization/role_list.html
@@ -6,21 +6,29 @@
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 text-gray-100">Roles</h1>
11 <div class="mt-4 md:mt-0 flex gap-3">
12 {% if not roles %}
13 {% if perms.organization.change_organization or user.is_superuser %}
14 <form method="post" action="{% url 'organization:role_initialize' %}">
15 {% csrf_token %}
16 <button type="submit"
17 class="inline-flex items-center 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">
18 Initialize Roles
19 </button>
20 </form>
21 {% endif %}
22 {% endif %}
23 {% if perms.organization.change_organization or user.is_superuser %}
24 <a href="{% url 'organization:role_create' %}"
25 class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
26 Create Role
27 </a>
28 {% endif %}
29 </div>
30 </div>
31
32 {% if roles %}
33 <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
34 {% for role in roles %}
35
--- templates/pages/page_form.html
+++ templates/pages/page_form.html
@@ -1,11 +1,11 @@
11
{% extends "base.html" %}
22
{% block title %}{{ title }} — Fossilrepo{% endblock %}
33
44
{% block content %}
55
<div class="mb-6">
6
- <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Knowledge Base</a>
6
+ <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Fossilrepo KB</a>
77
</div>
88
99
<div class="mx-auto max-w-4xl">
1010
<h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
1111
1212
--- templates/pages/page_form.html
+++ templates/pages/page_form.html
@@ -1,11 +1,11 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Knowledge Base</a>
7 </div>
8
9 <div class="mx-auto max-w-4xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12
--- templates/pages/page_form.html
+++ templates/pages/page_form.html
@@ -1,11 +1,11 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Fossilrepo KB</a>
7 </div>
8
9 <div class="mx-auto max-w-4xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12
--- templates/pages/page_list.html
+++ templates/pages/page_list.html
@@ -1,11 +1,11 @@
11
{% extends "base.html" %}
2
-{% block title %}Knowledge Base — Fossilrepo{% endblock %}
2
+{% block title %}Fossilrepo KB — Fossilrepo{% endblock %}
33
44
{% block content %}
55
<div class="md:flex md:items-center md:justify-between mb-6">
6
- <h1 class="text-2xl font-bold text-gray-100">Knowledge Base</h1>
6
+ <h1 class="text-2xl font-bold text-gray-100">Fossilrepo KB</h1>
77
{% if perms.pages.add_page %}
88
<a href="{% url 'pages:create' %}"
99
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">
1010
New Page
1111
</a>
1212
--- templates/pages/page_list.html
+++ templates/pages/page_list.html
@@ -1,11 +1,11 @@
1 {% extends "base.html" %}
2 {% block title %}Knowledge Base — 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">Knowledge Base</h1>
7 {% if perms.pages.add_page %}
8 <a href="{% url 'pages: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 Page
11 </a>
12
--- templates/pages/page_list.html
+++ templates/pages/page_list.html
@@ -1,11 +1,11 @@
1 {% extends "base.html" %}
2 {% block title %}Fossilrepo KB — 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">Fossilrepo KB</h1>
7 {% if perms.pages.add_page %}
8 <a href="{% url 'pages: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 Page
11 </a>
12
--- testdata/management/commands/seed.py
+++ testdata/management/commands/seed.py
@@ -1,11 +1,11 @@
11
import logging
22
33
from django.contrib.auth.models import Group, Permission, User
44
from django.core.management.base import BaseCommand
55
6
-from organization.models import Organization, OrganizationMember, Team
6
+from organization.models import Organization, OrganizationMember, OrgRole, Team
77
from pages.models import Page
88
from projects.models import Project, ProjectTeam
99
1010
logger = logging.getLogger(__name__)
1111
@@ -127,6 +127,52 @@
127127
Page.objects.get_or_create(
128128
name=pdata["name"],
129129
defaults={**pdata, "organization": org, "created_by": admin_user},
130130
)
131131
132
+ # --- Seed sample users per role ---
133
+ roles = OrgRole.objects.all()
134
+ if not roles.exists():
135
+ from django.core.management import call_command
136
+
137
+ call_command("seed_roles")
138
+ roles = OrgRole.objects.all()
139
+
140
+ role_users = {
141
+ "admin": {"email": "[email protected]", "first_name": "Admin", "last_name": "User"},
142
+ "manager": {"email": "[email protected]", "first_name": "Manager", "last_name": "User"},
143
+ "developer": {"email": "[email protected]", "first_name": "Dev", "last_name": "User"},
144
+ "viewer": {"email": "[email protected]", "first_name": "Viewer", "last_name": "RoleUser"},
145
+ }
146
+
147
+ for role in roles:
148
+ slug = role.slug
149
+ if slug not in role_users:
150
+ continue
151
+ info = role_users[slug]
152
+ username = f"role-{slug}"
153
+ user, created = User.objects.get_or_create(
154
+ username=username,
155
+ defaults={
156
+ "email": info["email"],
157
+ "first_name": info["first_name"],
158
+ "last_name": info["last_name"],
159
+ "is_active": True,
160
+ },
161
+ )
162
+ if created:
163
+ user.set_password(username)
164
+ user.save()
165
+
166
+ membership, _ = OrganizationMember.objects.get_or_create(
167
+ member=user,
168
+ organization=org,
169
+ defaults={"created_by": admin_user},
170
+ )
171
+ if membership.role != role:
172
+ membership.role = role
173
+ membership.save()
174
+ role.apply_to_user(user)
175
+
176
+ self.stdout.write(f" User: {username} / {username} (role: {role.name})")
177
+
132178
self.stdout.write(self.style.SUCCESS("Seed complete."))
133179
134180
ADDED tests/test_explorer.py
--- testdata/management/commands/seed.py
+++ testdata/management/commands/seed.py
@@ -1,11 +1,11 @@
1 import logging
2
3 from django.contrib.auth.models import Group, Permission, User
4 from django.core.management.base import BaseCommand
5
6 from organization.models import Organization, OrganizationMember, Team
7 from pages.models import Page
8 from projects.models import Project, ProjectTeam
9
10 logger = logging.getLogger(__name__)
11
@@ -127,6 +127,52 @@
127 Page.objects.get_or_create(
128 name=pdata["name"],
129 defaults={**pdata, "organization": org, "created_by": admin_user},
130 )
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132 self.stdout.write(self.style.SUCCESS("Seed complete."))
133
134 DDED tests/test_explorer.py
--- testdata/management/commands/seed.py
+++ testdata/management/commands/seed.py
@@ -1,11 +1,11 @@
1 import logging
2
3 from django.contrib.auth.models import Group, Permission, User
4 from django.core.management.base import BaseCommand
5
6 from organization.models import Organization, OrganizationMember, OrgRole, Team
7 from pages.models import Page
8 from projects.models import Project, ProjectTeam
9
10 logger = logging.getLogger(__name__)
11
@@ -127,6 +127,52 @@
127 Page.objects.get_or_create(
128 name=pdata["name"],
129 defaults={**pdata, "organization": org, "created_by": admin_user},
130 )
131
132 # --- Seed sample users per role ---
133 roles = OrgRole.objects.all()
134 if not roles.exists():
135 from django.core.management import call_command
136
137 call_command("seed_roles")
138 roles = OrgRole.objects.all()
139
140 role_users = {
141 "admin": {"email": "[email protected]", "first_name": "Admin", "last_name": "User"},
142 "manager": {"email": "[email protected]", "first_name": "Manager", "last_name": "User"},
143 "developer": {"email": "[email protected]", "first_name": "Dev", "last_name": "User"},
144 "viewer": {"email": "[email protected]", "first_name": "Viewer", "last_name": "RoleUser"},
145 }
146
147 for role in roles:
148 slug = role.slug
149 if slug not in role_users:
150 continue
151 info = role_users[slug]
152 username = f"role-{slug}"
153 user, created = User.objects.get_or_create(
154 username=username,
155 defaults={
156 "email": info["email"],
157 "first_name": info["first_name"],
158 "last_name": info["last_name"],
159 "is_active": True,
160 },
161 )
162 if created:
163 user.set_password(username)
164 user.save()
165
166 membership, _ = OrganizationMember.objects.get_or_create(
167 member=user,
168 organization=org,
169 defaults={"created_by": admin_user},
170 )
171 if membership.role != role:
172 membership.role = role
173 membership.save()
174 role.apply_to_user(user)
175
176 self.stdout.write(f" User: {username} / {username} (role: {role.name})")
177
178 self.stdout.write(self.style.SUCCESS("Seed complete."))
179
180 DDED tests/test_explorer.py
--- a/tests/test_explorer.py
+++ b/tests/test_explorer.py
@@ -0,0 +1,381 @@
1
+"""Tests for the SQLite schema explorer views."""
2
+
3
+import sqlite3
4
+from pathlib import Path
5
+from unittest.mock import patch
6
+
7
+import pytest
8
+from django.contrib.auth.models import User
9
+from django.test import Client
10
+
11
+from fossil.models import FossilRepository
12
+from fossil.reader import FossilReader
13
+from organization.models import Team
14
+from projects.models import ProjectTeam
15
+
16
+# Reusable patch that makes FossilRepository.exists_on_disk return True
17
+_disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
18
+
19
+
20
+@pytest.fixture
21
+def fossil_repo_obj(sample_project):
22
+ """Return the auto-created FossilRepository for sample_project."""
23
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
24
+
25
+
26
+@pytest.fixture
27
+def writer_user(db, admin_user, sample_project):
28
+ """User with write access but not admin."""
29
+ writer = User.objects.create_user(username="writer", password="testpass123")
30
+ team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
31
+ team.members.add(writer)
32
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
33
+ return writer
34
+
35
+
36
+@pytest.fixture
37
+def writer_client(writer_user):
38
+ client = Client()
39
+ client.login(username="writer", password="testpass123")
40
+ return client
41
+
42
+
43
+@pytest.fixture
44
+def reader_user(db, admin_user, sample_project):
45
+ """User with read access only."""
46
+ reader = User.objects.create_user(username="reader", password="testpass123")
47
+ team = Team.objects.create(name="Readers", organization=sample_project.organization, created_by=admin_user)
48
+ team.members.add(reader)
49
+ ProjectTeam.objects.create(project=sample_project, team=team, role="read", created_by=admin_user)
50
+ return reader
51
+
52
+
53
+@pytest.fixture
54
+def reader_client(reader_user):
55
+ client = Client()
56
+ client.login(username="reader", password="testpass123")
57
+ return client
58
+
59
+
60
+def _create_explorer_fossil_db(path: Path):
61
+ """Create a minimal .fossil SQLite database with typical Fossil tables."""
62
+ conn = sqlite3.connect(str(path))
63
+ conn.execute("CREATE TABLE blob (rid INTEGER PRIMARY KEY, uuid TEXT UNIQUE NOT NULL, size INTEGER NOT NULL DEFAULT 0, content BLOB)")
64
+ conn.execute("CREATE TABLE event (type TEXT, mtime REAL, objid INTEGER, user TEXT, comment TEXT)")
65
+ conn.execute("CREATE TABLE tag (tagid INTEGER PRIMARY KEY, tagname TEXT UNIQUE)")
66
+ conn.execute("CREATE TABLE tagxref (tagid INTEGER, tagtype INTEGER, srcid INTEGER, origid INTEGER, value TEXT, mtime REAL)")
67
+ conn.execute("CREATE TABLE delta (rid INTEGER, srcid INTEGER)")
68
+ conn.execute("CREATE TABLE leaf (rid INTEGER)")
69
+ conn.execute("CREATE TABLE phantom (rid INTEGER)")
70
+ conn.execute("CREATE TABLE ticket (tkt_id INTEGER PRIMARY KEY, tkt_uuid TEXT, title TEXT, status TEXT)")
71
+
72
+ # Insert sample data
73
+ conn.execute("INSERT INTO blob (rid, uuid, size) VALUES (1, 'abc123def456', 100)")
74
+ conn.execute("INSERT INTO blob (rid, uuid, size) VALUES (2, '789012345678', 200)")
75
+ conn.execute("INSERT INTO event (type, mtime, objid, user, comment) VALUES ('ci', 2460676.5, 1, 'admin', 'initial')")
76
+ conn.execute("INSERT INTO tag (tagid, tagname) VALUES (1, 'sym-trunk')")
77
+ conn.execute("INSERT INTO tagxref (tagid, tagtype, srcid, origid, value, mtime) VALUES (1, 2, 1, 1, 'trunk', 2460676.5)")
78
+ conn.execute("INSERT INTO ticket (tkt_id, tkt_uuid, title, status) VALUES (1, 'tkt001', 'Fix bug', 'Open')")
79
+ conn.commit()
80
+ return conn
81
+
82
+
83
+@pytest.fixture
84
+def explorer_fossil_db(tmp_path):
85
+ """Create a temporary .fossil file for explorer tests."""
86
+ db_path = tmp_path / "explorer-test.fossil"
87
+ conn = _create_explorer_fossil_db(db_path)
88
+ conn.close()
89
+ return db_path
90
+
91
+
92
+def _make_fossil_reader_cls(db_path):
93
+ """Return a FossilReader class replacement that always opens the given test DB.
94
+
95
+ Unlike a full mock, this returns a real FossilReader pointing at our test
96
+ .fossil file so that the explorer views can execute real SQL.
97
+ """
98
+ original_cls = FossilReader
99
+
100
+ class TestFossilReader(original_cls):
101
+ def __init__(self, path):
102
+ super().__init__(db_path)
103
+
104
+ return TestFossilReader
105
+
106
+
107
+# --- Explorer main page ---
108
+
109
+
110
+@pytest.mark.django_db
111
+class TestExplorerView:
112
+ def test_loads_for_admin(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
113
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
114
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
115
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
116
+ assert response.status_code == 200
117
+ content = response.content.decode()
118
+ assert "Schema Explorer" in content
119
+ assert "blob" in content
120
+ assert "event" in content
121
+ assert "ticket" in content
122
+
123
+ def test_shows_row_counts(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
124
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
125
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
126
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
127
+ content = response.content.decode()
128
+ # blob has 2 rows
129
+ assert "2" in content
130
+
131
+ def test_shows_relationships(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
132
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
133
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
134
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
135
+ content = response.content.decode()
136
+ # Schema map section should be present
137
+ assert "Schema Map" in content
138
+
139
+ def test_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, explorer_fossil_db):
140
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
141
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
142
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
143
+ assert response.status_code == 403
144
+
145
+ def test_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj, explorer_fossil_db):
146
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
147
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
148
+ response = reader_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
149
+ assert response.status_code == 403
150
+
151
+ def test_denied_for_anonymous(self, client, sample_project):
152
+ response = client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
153
+ assert response.status_code == 302 # redirect to login
154
+
155
+
156
+# --- Explorer table detail ---
157
+
158
+
159
+@pytest.mark.django_db
160
+class TestExplorerTableView:
161
+ def test_returns_table_columns(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
162
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
163
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
164
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
165
+ assert response.status_code == 200
166
+ content = response.content.decode()
167
+ assert "blob" in content
168
+ assert "rid" in content
169
+ assert "uuid" in content
170
+ assert "size" in content
171
+
172
+ def test_returns_sample_rows(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
173
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
174
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
175
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
176
+ content = response.content.decode()
177
+ assert "abc123def456" in content
178
+ assert "789012345678" in content
179
+
180
+ def test_returns_row_count(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
181
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
182
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
183
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
184
+ content = response.content.decode()
185
+ assert "2 rows" in content
186
+
187
+ def test_nonexistent_table_404(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
188
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
189
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
190
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/nonexistent/")
191
+ assert response.status_code == 404
192
+
193
+ def test_invalid_table_name_404(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
194
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
195
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
196
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/drop%20table/")
197
+ assert response.status_code == 404
198
+
199
+ def test_sql_injection_table_name_404(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
200
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
201
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
202
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob;DROP/")
203
+ assert response.status_code == 404
204
+
205
+ def test_pagination(self, admin_client, sample_project, fossil_repo_obj, tmp_path):
206
+ """Test that pagination works for tables with more than 25 rows."""
207
+ db_path = tmp_path / "paged.fossil"
208
+ conn = sqlite3.connect(str(db_path))
209
+ conn.execute("CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)")
210
+ for i in range(60):
211
+ conn.execute("INSERT INTO test_data (id, value) VALUES (?, ?)", (i, f"val-{i}"))
212
+ conn.commit()
213
+ conn.close()
214
+
215
+ reader_cls = _make_fossil_reader_cls(db_path)
216
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
217
+ # Page 1
218
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/test_data/")
219
+ content = response.content.decode()
220
+ assert "val-0" in content
221
+ assert "Next" in content
222
+
223
+ # Page 2
224
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/test_data/?page=2")
225
+ content = response.content.decode()
226
+ assert "val-25" in content
227
+ assert "Previous" in content
228
+
229
+ def test_empty_table(self, admin_client, sample_project, fossil_repo_obj, tmp_path):
230
+ db_path = tmp_path / "empty.fossil"
231
+ conn = sqlite3.connect(str(db_path))
232
+ conn.execute("CREATE TABLE empty_table (id INTEGER PRIMARY KEY)")
233
+ conn.commit()
234
+ conn.close()
235
+
236
+ reader_cls = _make_fossil_reader_cls(db_path)
237
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
238
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/empty_table/")
239
+ assert response.status_code == 200
240
+ assert "Table is empty" in response.content.decode()
241
+
242
+ def test_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, explorer_fossil_db):
243
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
244
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
245
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
246
+ assert response.status_code == 403
247
+
248
+
249
+# --- Explorer query runner ---
250
+
251
+
252
+@pytest.mark.django_db
253
+class TestExplorerQueryView:
254
+ def test_query_page_loads(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
255
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
256
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
257
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
258
+ assert response.status_code == 200
259
+ content = response.content.decode()
260
+ assert "Query Runner" in content
261
+
262
+ def test_run_select_query(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
263
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
264
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
265
+ response = admin_client.get(
266
+ f"/projects/{sample_project.slug}/fossil/explorer/query/",
267
+ {"sql": "SELECT uuid, size FROM blob ORDER BY rid"},
268
+ )
269
+ assert response.status_code == 200
270
+ content = response.content.decode()
271
+ assert "abc123def456" in content
272
+ assert "100" in content
273
+ assert "2 rows" in content
274
+
275
+ def test_reject_insert(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
276
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
277
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
278
+ response = admin_client.get(
279
+ f"/projects/{sample_project.slug}/fossil/explorer/query/",
280
+ {"sql": "INSERT INTO blob (rid, uuid, size) VALUES (99, 'evil', 0)"},
281
+ )
282
+ content = response.content.decode()
283
+ assert "SELECT" in content # error message about requiring SELECT
284
+
285
+ def test_reject_drop(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
286
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
287
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
288
+ response = admin_client.get(
289
+ f"/projects/{sample_project.slug}/fossil/explorer/query/",
290
+ {"sql": "DROP TABLE blob"},
291
+ )
292
+ content = response.content.decode()
293
+ assert "forbidden" in content.lower() or "SELECT" in content
294
+
295
+ def test_reject_delete(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
296
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
297
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
298
+ response = admin_client.get(
299
+ f"/projects/{sample_project.slug}/fossil/explorer/query/",
300
+ {"sql": "DELETE FROM blob"},
301
+ )
302
+ content = response.content.decode()
303
+ assert "forbidden" in content.lower() or "SELECT" in content
304
+
305
+ def test_reject_multiple_statements(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
306
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
307
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
308
+ response = admin_client.get(
309
+ f"/projects/{sample_project.slug}/fossil/explorer/query/",
310
+ {"sql": "SELECT 1; SELECT 2"},
311
+ )
312
+ content = response.content.decode()
313
+ assert "multiple" in content.lower()
314
+
315
+ def test_handles_invalid_sql(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
316
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
317
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
318
+ response = admin_client.get(
319
+ f"/projects/{sample_project.slug}/fossil/explorer/query/",
320
+ {"sql": "SELECT * FROM this_table_does_not_exist"},
321
+ )
322
+ content = response.content.decode()
323
+ # Should show an error, not crash
324
+ assert "no such table" in content.lower()
325
+
326
+ def test_empty_query_no_results(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
327
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
328
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
329
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
330
+ assert response.status_code == 200
331
+ content = response.content.decode()
332
+ # Should show available tables sidebar
333
+ assert "Available Tables" in content
334
+
335
+ def test_shows_table_names_sidebar(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
336
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
337
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
338
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
339
+ content = response.content.decode()
340
+ assert "blob" in content
341
+ assert "event" in content
342
+
343
+ def test_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, explorer_fossil_db):
344
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
345
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
346
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
347
+ assert response.status_code == 403
348
+
349
+ def test_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj, explorer_fossil_db):
350
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
351
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
352
+ response = reader_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
353
+ assert response.status_code == 403
354
+
355
+ def test_denied_for_anonymous(self, client, sample_project):
356
+ response = client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
357
+ assert response.status_code == 302 # redirect to login
358
+
359
+
360
+# --- URL routing ---
361
+
362
+
363
+@pytest.mark.django_db
364
+class TestExplorerURLs:
365
+ def test_explorer_url_resolves(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
366
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
367
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
368
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
369
+ assert response.status_code == 200
370
+
371
+ def test_explorer_table_url_resolves(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
372
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
373
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
374
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
375
+ assert response.status_code == 200
376
+
377
+ def test_explorer_query_url_resolves(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
378
+ reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
379
+ with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
380
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
381
+ assert response.status_code == 200
--- a/tests/test_explorer.py
+++ b/tests/test_explorer.py
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_explorer.py
+++ b/tests/test_explorer.py
@@ -0,0 +1,381 @@
1 """Tests for the SQLite schema explorer views."""
2
3 import sqlite3
4 from pathlib import Path
5 from unittest.mock import patch
6
7 import pytest
8 from django.contrib.auth.models import User
9 from django.test import Client
10
11 from fossil.models import FossilRepository
12 from fossil.reader import FossilReader
13 from organization.models import Team
14 from projects.models import ProjectTeam
15
16 # Reusable patch that makes FossilRepository.exists_on_disk return True
17 _disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
18
19
20 @pytest.fixture
21 def fossil_repo_obj(sample_project):
22 """Return the auto-created FossilRepository for sample_project."""
23 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
24
25
26 @pytest.fixture
27 def writer_user(db, admin_user, sample_project):
28 """User with write access but not admin."""
29 writer = User.objects.create_user(username="writer", password="testpass123")
30 team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
31 team.members.add(writer)
32 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
33 return writer
34
35
36 @pytest.fixture
37 def writer_client(writer_user):
38 client = Client()
39 client.login(username="writer", password="testpass123")
40 return client
41
42
43 @pytest.fixture
44 def reader_user(db, admin_user, sample_project):
45 """User with read access only."""
46 reader = User.objects.create_user(username="reader", password="testpass123")
47 team = Team.objects.create(name="Readers", organization=sample_project.organization, created_by=admin_user)
48 team.members.add(reader)
49 ProjectTeam.objects.create(project=sample_project, team=team, role="read", created_by=admin_user)
50 return reader
51
52
53 @pytest.fixture
54 def reader_client(reader_user):
55 client = Client()
56 client.login(username="reader", password="testpass123")
57 return client
58
59
60 def _create_explorer_fossil_db(path: Path):
61 """Create a minimal .fossil SQLite database with typical Fossil tables."""
62 conn = sqlite3.connect(str(path))
63 conn.execute("CREATE TABLE blob (rid INTEGER PRIMARY KEY, uuid TEXT UNIQUE NOT NULL, size INTEGER NOT NULL DEFAULT 0, content BLOB)")
64 conn.execute("CREATE TABLE event (type TEXT, mtime REAL, objid INTEGER, user TEXT, comment TEXT)")
65 conn.execute("CREATE TABLE tag (tagid INTEGER PRIMARY KEY, tagname TEXT UNIQUE)")
66 conn.execute("CREATE TABLE tagxref (tagid INTEGER, tagtype INTEGER, srcid INTEGER, origid INTEGER, value TEXT, mtime REAL)")
67 conn.execute("CREATE TABLE delta (rid INTEGER, srcid INTEGER)")
68 conn.execute("CREATE TABLE leaf (rid INTEGER)")
69 conn.execute("CREATE TABLE phantom (rid INTEGER)")
70 conn.execute("CREATE TABLE ticket (tkt_id INTEGER PRIMARY KEY, tkt_uuid TEXT, title TEXT, status TEXT)")
71
72 # Insert sample data
73 conn.execute("INSERT INTO blob (rid, uuid, size) VALUES (1, 'abc123def456', 100)")
74 conn.execute("INSERT INTO blob (rid, uuid, size) VALUES (2, '789012345678', 200)")
75 conn.execute("INSERT INTO event (type, mtime, objid, user, comment) VALUES ('ci', 2460676.5, 1, 'admin', 'initial')")
76 conn.execute("INSERT INTO tag (tagid, tagname) VALUES (1, 'sym-trunk')")
77 conn.execute("INSERT INTO tagxref (tagid, tagtype, srcid, origid, value, mtime) VALUES (1, 2, 1, 1, 'trunk', 2460676.5)")
78 conn.execute("INSERT INTO ticket (tkt_id, tkt_uuid, title, status) VALUES (1, 'tkt001', 'Fix bug', 'Open')")
79 conn.commit()
80 return conn
81
82
83 @pytest.fixture
84 def explorer_fossil_db(tmp_path):
85 """Create a temporary .fossil file for explorer tests."""
86 db_path = tmp_path / "explorer-test.fossil"
87 conn = _create_explorer_fossil_db(db_path)
88 conn.close()
89 return db_path
90
91
92 def _make_fossil_reader_cls(db_path):
93 """Return a FossilReader class replacement that always opens the given test DB.
94
95 Unlike a full mock, this returns a real FossilReader pointing at our test
96 .fossil file so that the explorer views can execute real SQL.
97 """
98 original_cls = FossilReader
99
100 class TestFossilReader(original_cls):
101 def __init__(self, path):
102 super().__init__(db_path)
103
104 return TestFossilReader
105
106
107 # --- Explorer main page ---
108
109
110 @pytest.mark.django_db
111 class TestExplorerView:
112 def test_loads_for_admin(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
113 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
114 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
115 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
116 assert response.status_code == 200
117 content = response.content.decode()
118 assert "Schema Explorer" in content
119 assert "blob" in content
120 assert "event" in content
121 assert "ticket" in content
122
123 def test_shows_row_counts(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
124 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
125 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
126 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
127 content = response.content.decode()
128 # blob has 2 rows
129 assert "2" in content
130
131 def test_shows_relationships(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
132 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
133 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
134 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
135 content = response.content.decode()
136 # Schema map section should be present
137 assert "Schema Map" in content
138
139 def test_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, explorer_fossil_db):
140 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
141 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
142 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
143 assert response.status_code == 403
144
145 def test_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj, explorer_fossil_db):
146 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
147 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
148 response = reader_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
149 assert response.status_code == 403
150
151 def test_denied_for_anonymous(self, client, sample_project):
152 response = client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
153 assert response.status_code == 302 # redirect to login
154
155
156 # --- Explorer table detail ---
157
158
159 @pytest.mark.django_db
160 class TestExplorerTableView:
161 def test_returns_table_columns(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
162 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
163 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
164 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
165 assert response.status_code == 200
166 content = response.content.decode()
167 assert "blob" in content
168 assert "rid" in content
169 assert "uuid" in content
170 assert "size" in content
171
172 def test_returns_sample_rows(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
173 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
174 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
175 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
176 content = response.content.decode()
177 assert "abc123def456" in content
178 assert "789012345678" in content
179
180 def test_returns_row_count(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
181 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
182 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
183 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
184 content = response.content.decode()
185 assert "2 rows" in content
186
187 def test_nonexistent_table_404(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
188 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
189 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
190 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/nonexistent/")
191 assert response.status_code == 404
192
193 def test_invalid_table_name_404(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
194 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
195 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
196 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/drop%20table/")
197 assert response.status_code == 404
198
199 def test_sql_injection_table_name_404(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
200 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
201 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
202 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob;DROP/")
203 assert response.status_code == 404
204
205 def test_pagination(self, admin_client, sample_project, fossil_repo_obj, tmp_path):
206 """Test that pagination works for tables with more than 25 rows."""
207 db_path = tmp_path / "paged.fossil"
208 conn = sqlite3.connect(str(db_path))
209 conn.execute("CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)")
210 for i in range(60):
211 conn.execute("INSERT INTO test_data (id, value) VALUES (?, ?)", (i, f"val-{i}"))
212 conn.commit()
213 conn.close()
214
215 reader_cls = _make_fossil_reader_cls(db_path)
216 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
217 # Page 1
218 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/test_data/")
219 content = response.content.decode()
220 assert "val-0" in content
221 assert "Next" in content
222
223 # Page 2
224 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/test_data/?page=2")
225 content = response.content.decode()
226 assert "val-25" in content
227 assert "Previous" in content
228
229 def test_empty_table(self, admin_client, sample_project, fossil_repo_obj, tmp_path):
230 db_path = tmp_path / "empty.fossil"
231 conn = sqlite3.connect(str(db_path))
232 conn.execute("CREATE TABLE empty_table (id INTEGER PRIMARY KEY)")
233 conn.commit()
234 conn.close()
235
236 reader_cls = _make_fossil_reader_cls(db_path)
237 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
238 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/empty_table/")
239 assert response.status_code == 200
240 assert "Table is empty" in response.content.decode()
241
242 def test_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, explorer_fossil_db):
243 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
244 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
245 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
246 assert response.status_code == 403
247
248
249 # --- Explorer query runner ---
250
251
252 @pytest.mark.django_db
253 class TestExplorerQueryView:
254 def test_query_page_loads(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
255 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
256 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
257 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
258 assert response.status_code == 200
259 content = response.content.decode()
260 assert "Query Runner" in content
261
262 def test_run_select_query(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
263 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
264 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
265 response = admin_client.get(
266 f"/projects/{sample_project.slug}/fossil/explorer/query/",
267 {"sql": "SELECT uuid, size FROM blob ORDER BY rid"},
268 )
269 assert response.status_code == 200
270 content = response.content.decode()
271 assert "abc123def456" in content
272 assert "100" in content
273 assert "2 rows" in content
274
275 def test_reject_insert(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
276 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
277 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
278 response = admin_client.get(
279 f"/projects/{sample_project.slug}/fossil/explorer/query/",
280 {"sql": "INSERT INTO blob (rid, uuid, size) VALUES (99, 'evil', 0)"},
281 )
282 content = response.content.decode()
283 assert "SELECT" in content # error message about requiring SELECT
284
285 def test_reject_drop(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
286 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
287 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
288 response = admin_client.get(
289 f"/projects/{sample_project.slug}/fossil/explorer/query/",
290 {"sql": "DROP TABLE blob"},
291 )
292 content = response.content.decode()
293 assert "forbidden" in content.lower() or "SELECT" in content
294
295 def test_reject_delete(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
296 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
297 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
298 response = admin_client.get(
299 f"/projects/{sample_project.slug}/fossil/explorer/query/",
300 {"sql": "DELETE FROM blob"},
301 )
302 content = response.content.decode()
303 assert "forbidden" in content.lower() or "SELECT" in content
304
305 def test_reject_multiple_statements(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
306 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
307 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
308 response = admin_client.get(
309 f"/projects/{sample_project.slug}/fossil/explorer/query/",
310 {"sql": "SELECT 1; SELECT 2"},
311 )
312 content = response.content.decode()
313 assert "multiple" in content.lower()
314
315 def test_handles_invalid_sql(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
316 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
317 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
318 response = admin_client.get(
319 f"/projects/{sample_project.slug}/fossil/explorer/query/",
320 {"sql": "SELECT * FROM this_table_does_not_exist"},
321 )
322 content = response.content.decode()
323 # Should show an error, not crash
324 assert "no such table" in content.lower()
325
326 def test_empty_query_no_results(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
327 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
328 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
329 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
330 assert response.status_code == 200
331 content = response.content.decode()
332 # Should show available tables sidebar
333 assert "Available Tables" in content
334
335 def test_shows_table_names_sidebar(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
336 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
337 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
338 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
339 content = response.content.decode()
340 assert "blob" in content
341 assert "event" in content
342
343 def test_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, explorer_fossil_db):
344 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
345 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
346 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
347 assert response.status_code == 403
348
349 def test_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj, explorer_fossil_db):
350 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
351 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
352 response = reader_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
353 assert response.status_code == 403
354
355 def test_denied_for_anonymous(self, client, sample_project):
356 response = client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
357 assert response.status_code == 302 # redirect to login
358
359
360 # --- URL routing ---
361
362
363 @pytest.mark.django_db
364 class TestExplorerURLs:
365 def test_explorer_url_resolves(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
366 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
367 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
368 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/")
369 assert response.status_code == 200
370
371 def test_explorer_table_url_resolves(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
372 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
373 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
374 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/table/blob/")
375 assert response.status_code == 200
376
377 def test_explorer_query_url_resolves(self, admin_client, sample_project, fossil_repo_obj, explorer_fossil_db):
378 reader_cls = _make_fossil_reader_cls(explorer_fossil_db)
379 with _disk_patch, patch("fossil.views.FossilReader", reader_cls):
380 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/explorer/query/")
381 assert response.status_code == 200
--- tests/test_roles.py
+++ tests/test_roles.py
@@ -396,5 +396,264 @@
396396
def test_detail_shows_no_role_assigned(self, admin_client, org, target_user):
397397
response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
398398
assert response.status_code == 200
399399
content = response.content.decode()
400400
assert "No role assigned" in content
401
+
402
+
403
+# --- role_create view ---
404
+
405
+
406
+@pytest.mark.django_db
407
+class TestRoleCreateView:
408
+ def test_create_get_shows_form(self, admin_client, org):
409
+ response = admin_client.get(reverse("organization:role_create"))
410
+ assert response.status_code == 200
411
+ content = response.content.decode()
412
+ assert "New Role" in content
413
+ assert "Permissions" in content
414
+
415
+ def test_create_saves_role(self, admin_client, org):
416
+ perm = Permission.objects.filter(content_type__app_label="organization", codename="view_organization").first()
417
+ response = admin_client.post(
418
+ reverse("organization:role_create"),
419
+ {"name": "Custom Role", "description": "A custom role", "permissions": [perm.pk]},
420
+ )
421
+ assert response.status_code == 302
422
+ role = OrgRole.objects.get(slug="custom-role")
423
+ assert role.name == "Custom Role"
424
+ assert role.description == "A custom role"
425
+ assert perm in role.permissions.all()
426
+
427
+ def test_create_without_permissions(self, admin_client, org):
428
+ response = admin_client.post(
429
+ reverse("organization:role_create"),
430
+ {"name": "Empty Role", "description": "No permissions"},
431
+ )
432
+ assert response.status_code == 302
433
+ role = OrgRole.objects.get(slug="empty-role")
434
+ assert role.permissions.count() == 0
435
+
436
+ def test_create_with_is_default(self, admin_client, org):
437
+ response = admin_client.post(
438
+ reverse("organization:role_create"),
439
+ {"name": "Default Custom", "description": "Default", "is_default": "on"},
440
+ )
441
+ assert response.status_code == 302
442
+ role = OrgRole.objects.get(slug="default-custom")
443
+ assert role.is_default is True
444
+
445
+ def test_create_denied_for_viewer(self, viewer_client, org):
446
+ response = viewer_client.get(reverse("organization:role_create"))
447
+ assert response.status_code == 403
448
+
449
+ def test_create_denied_for_no_perm(self, no_perm_client, org):
450
+ response = no_perm_client.get(reverse("organization:role_create"))
451
+ assert response.status_code == 403
452
+
453
+ def test_create_denied_for_anon(self, client, org):
454
+ response = client.get(reverse("organization:role_create"))
455
+ assert response.status_code == 302 # redirect to login
456
+
457
+ def test_create_allowed_for_org_admin(self, org_admin_client, org):
458
+ response = org_admin_client.post(
459
+ reverse("organization:role_create"),
460
+ {"name": "OrgAdmin Role", "description": "Created by org admin"},
461
+ )
462
+ assert response.status_code == 302
463
+ assert OrgRole.objects.filter(slug="orgadmin-role").exists()
464
+
465
+ def test_create_sets_created_by(self, admin_client, org, admin_user):
466
+ response = admin_client.post(
467
+ reverse("organization:role_create"),
468
+ {"name": "Tracked Role", "description": "test"},
469
+ )
470
+ assert response.status_code == 302
471
+ role = OrgRole.objects.get(slug="tracked-role")
472
+ assert role.created_by == admin_user
473
+
474
+ def test_create_invalid_missing_name(self, admin_client, org):
475
+ response = admin_client.post(
476
+ reverse("organization:role_create"),
477
+ {"description": "Missing name"},
478
+ )
479
+ assert response.status_code == 200 # re-renders form
480
+ assert OrgRole.objects.count() == 0
481
+
482
+
483
+# --- role_edit view ---
484
+
485
+
486
+@pytest.mark.django_db
487
+class TestRoleEditView:
488
+ def test_edit_get_shows_form(self, admin_client, org, viewer_role):
489
+ response = admin_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
490
+ assert response.status_code == 200
491
+ content = response.content.decode()
492
+ assert "Edit Viewer" in content
493
+ assert "Permissions" in content
494
+
495
+ def test_edit_updates_role(self, admin_client, org, viewer_role):
496
+ response = admin_client.post(
497
+ reverse("organization:role_edit", kwargs={"slug": "viewer"}),
498
+ {"name": "Viewer Updated", "description": "Updated description"},
499
+ )
500
+ assert response.status_code == 302
501
+ viewer_role.refresh_from_db()
502
+ assert viewer_role.name == "Viewer Updated"
503
+ assert viewer_role.description == "Updated description"
504
+
505
+ def test_edit_updates_permissions(self, admin_client, org, viewer_role):
506
+ add_perm = Permission.objects.get(content_type__app_label="organization", codename="add_organization")
507
+ response = admin_client.post(
508
+ reverse("organization:role_edit", kwargs={"slug": "viewer"}),
509
+ {"name": "Viewer", "description": "Updated", "permissions": [add_perm.pk]},
510
+ )
511
+ assert response.status_code == 302
512
+ viewer_role.refresh_from_db()
513
+ assert list(viewer_role.permissions.values_list("pk", flat=True)) == [add_perm.pk]
514
+
515
+ def test_edit_clears_permissions(self, admin_client, org, viewer_role):
516
+ assert viewer_role.permissions.count() > 0
517
+ response = admin_client.post(
518
+ reverse("organization:role_edit", kwargs={"slug": "viewer"}),
519
+ {"name": "Viewer", "description": "No perms now"},
520
+ )
521
+ assert response.status_code == 302
522
+ viewer_role.refresh_from_db()
523
+ assert viewer_role.permissions.count() == 0
524
+
525
+ def test_edit_pre_populates_permissions(self, admin_client, org, viewer_role):
526
+ response = admin_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
527
+ assert response.status_code == 200
528
+ content = response.content.decode()
529
+ assert "checked" in content
530
+
531
+ def test_edit_sets_updated_by(self, admin_client, org, viewer_role, admin_user):
532
+ response = admin_client.post(
533
+ reverse("organization:role_edit", kwargs={"slug": "viewer"}),
534
+ {"name": "Viewer", "description": "Audit check"},
535
+ )
536
+ assert response.status_code == 302
537
+ viewer_role.refresh_from_db()
538
+ assert viewer_role.updated_by == admin_user
539
+
540
+ def test_edit_denied_for_viewer(self, viewer_client, org, viewer_role):
541
+ response = viewer_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
542
+ assert response.status_code == 403
543
+
544
+ def test_edit_denied_for_no_perm(self, no_perm_client, org, viewer_role):
545
+ response = no_perm_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
546
+ assert response.status_code == 403
547
+
548
+ def test_edit_denied_for_anon(self, client, org, viewer_role):
549
+ response = client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
550
+ assert response.status_code == 302 # redirect to login
551
+
552
+ def test_edit_404_for_deleted_role(self, admin_client, org, viewer_role, admin_user):
553
+ viewer_role.soft_delete(user=admin_user)
554
+ response = admin_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
555
+ assert response.status_code == 404
556
+
557
+ def test_edit_404_for_missing_role(self, admin_client, org):
558
+ response = admin_client.get(reverse("organization:role_edit", kwargs={"slug": "nonexistent"}))
559
+ assert response.status_code == 404
560
+
561
+
562
+# --- role_delete view ---
563
+
564
+
565
+@pytest.mark.django_db
566
+class TestRoleDeleteView:
567
+ def test_delete_get_shows_confirmation(self, admin_client, org, viewer_role):
568
+ response = admin_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
569
+ assert response.status_code == 200
570
+ content = response.content.decode()
571
+ assert "Delete Role" in content
572
+ assert "Viewer" in content
573
+
574
+ def test_delete_soft_deletes_role(self, admin_client, org, viewer_role):
575
+ response = admin_client.post(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
576
+ assert response.status_code == 302
577
+ viewer_role.refresh_from_db()
578
+ assert viewer_role.deleted_at is not None
579
+
580
+ def test_delete_blocked_when_members_assigned(self, admin_client, org, viewer_role, target_user):
581
+ membership = OrganizationMember.objects.get(member=target_user, organization=org)
582
+ membership.role = viewer_role
583
+ membership.save()
584
+
585
+ response = admin_client.post(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
586
+ assert response.status_code == 302 # redirects back to detail
587
+ viewer_role.refresh_from_db()
588
+ assert viewer_role.deleted_at is None # not deleted
589
+
590
+ def test_delete_shows_warning_for_members(self, admin_client, org, viewer_role, target_user):
591
+ membership = OrganizationMember.objects.get(member=target_user, organization=org)
592
+ membership.role = viewer_role
593
+ membership.save()
594
+
595
+ response = admin_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
596
+ assert response.status_code == 200
597
+ content = response.content.decode()
598
+ assert "active member" in content
599
+ assert "targetuser" in content
600
+
601
+ def test_delete_denied_for_viewer(self, viewer_client, org, viewer_role):
602
+ response = viewer_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
603
+ assert response.status_code == 403
604
+
605
+ def test_delete_denied_for_no_perm(self, no_perm_client, org, viewer_role):
606
+ response = no_perm_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
607
+ assert response.status_code == 403
608
+
609
+ def test_delete_denied_for_anon(self, client, org, viewer_role):
610
+ response = client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
611
+ assert response.status_code == 302 # redirect to login
612
+
613
+ def test_delete_404_for_deleted_role(self, admin_client, org, viewer_role, admin_user):
614
+ viewer_role.soft_delete(user=admin_user)
615
+ response = admin_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
616
+ assert response.status_code == 404
617
+
618
+ def test_delete_htmx_returns_redirect_header(self, admin_client, org, developer_role):
619
+ response = admin_client.post(
620
+ reverse("organization:role_delete", kwargs={"slug": "developer"}),
621
+ HTTP_HX_REQUEST="true",
622
+ )
623
+ assert response.status_code == 200
624
+ assert response.headers.get("HX-Redirect") == "/settings/roles/"
625
+
626
+
627
+# --- role_list Create Role button ---
628
+
629
+
630
+@pytest.mark.django_db
631
+class TestRoleListCreateButton:
632
+ def test_create_button_shown_for_admin(self, admin_client, org, roles):
633
+ response = admin_client.get(reverse("organization:role_list"))
634
+ assert response.status_code == 200
635
+ assert "Create Role" in response.content.decode()
636
+
637
+ def test_create_button_hidden_for_viewer(self, viewer_client, org, roles):
638
+ response = viewer_client.get(reverse("organization:role_list"))
639
+ assert response.status_code == 200
640
+ assert "Create Role" not in response.content.decode()
641
+
642
+
643
+# --- role_detail Edit/Delete buttons ---
644
+
645
+
646
+@pytest.mark.django_db
647
+class TestRoleDetailButtons:
648
+ def test_edit_button_shown_for_admin(self, admin_client, org, viewer_role):
649
+ response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"}))
650
+ assert response.status_code == 200
651
+ content = response.content.decode()
652
+ assert "Edit" in content
653
+ assert "Delete" in content
654
+
655
+ def test_edit_button_hidden_for_viewer(self, viewer_client, org, viewer_role):
656
+ response = viewer_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"}))
657
+ assert response.status_code == 200
658
+ content = response.content.decode()
659
+ assert "Edit" not in content or "role_edit" not in content
401660
--- tests/test_roles.py
+++ tests/test_roles.py
@@ -396,5 +396,264 @@
396 def test_detail_shows_no_role_assigned(self, admin_client, org, target_user):
397 response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
398 assert response.status_code == 200
399 content = response.content.decode()
400 assert "No role assigned" in content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
--- tests/test_roles.py
+++ tests/test_roles.py
@@ -396,5 +396,264 @@
396 def test_detail_shows_no_role_assigned(self, admin_client, org, target_user):
397 response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"}))
398 assert response.status_code == 200
399 content = response.content.decode()
400 assert "No role assigned" in content
401
402
403 # --- role_create view ---
404
405
406 @pytest.mark.django_db
407 class TestRoleCreateView:
408 def test_create_get_shows_form(self, admin_client, org):
409 response = admin_client.get(reverse("organization:role_create"))
410 assert response.status_code == 200
411 content = response.content.decode()
412 assert "New Role" in content
413 assert "Permissions" in content
414
415 def test_create_saves_role(self, admin_client, org):
416 perm = Permission.objects.filter(content_type__app_label="organization", codename="view_organization").first()
417 response = admin_client.post(
418 reverse("organization:role_create"),
419 {"name": "Custom Role", "description": "A custom role", "permissions": [perm.pk]},
420 )
421 assert response.status_code == 302
422 role = OrgRole.objects.get(slug="custom-role")
423 assert role.name == "Custom Role"
424 assert role.description == "A custom role"
425 assert perm in role.permissions.all()
426
427 def test_create_without_permissions(self, admin_client, org):
428 response = admin_client.post(
429 reverse("organization:role_create"),
430 {"name": "Empty Role", "description": "No permissions"},
431 )
432 assert response.status_code == 302
433 role = OrgRole.objects.get(slug="empty-role")
434 assert role.permissions.count() == 0
435
436 def test_create_with_is_default(self, admin_client, org):
437 response = admin_client.post(
438 reverse("organization:role_create"),
439 {"name": "Default Custom", "description": "Default", "is_default": "on"},
440 )
441 assert response.status_code == 302
442 role = OrgRole.objects.get(slug="default-custom")
443 assert role.is_default is True
444
445 def test_create_denied_for_viewer(self, viewer_client, org):
446 response = viewer_client.get(reverse("organization:role_create"))
447 assert response.status_code == 403
448
449 def test_create_denied_for_no_perm(self, no_perm_client, org):
450 response = no_perm_client.get(reverse("organization:role_create"))
451 assert response.status_code == 403
452
453 def test_create_denied_for_anon(self, client, org):
454 response = client.get(reverse("organization:role_create"))
455 assert response.status_code == 302 # redirect to login
456
457 def test_create_allowed_for_org_admin(self, org_admin_client, org):
458 response = org_admin_client.post(
459 reverse("organization:role_create"),
460 {"name": "OrgAdmin Role", "description": "Created by org admin"},
461 )
462 assert response.status_code == 302
463 assert OrgRole.objects.filter(slug="orgadmin-role").exists()
464
465 def test_create_sets_created_by(self, admin_client, org, admin_user):
466 response = admin_client.post(
467 reverse("organization:role_create"),
468 {"name": "Tracked Role", "description": "test"},
469 )
470 assert response.status_code == 302
471 role = OrgRole.objects.get(slug="tracked-role")
472 assert role.created_by == admin_user
473
474 def test_create_invalid_missing_name(self, admin_client, org):
475 response = admin_client.post(
476 reverse("organization:role_create"),
477 {"description": "Missing name"},
478 )
479 assert response.status_code == 200 # re-renders form
480 assert OrgRole.objects.count() == 0
481
482
483 # --- role_edit view ---
484
485
486 @pytest.mark.django_db
487 class TestRoleEditView:
488 def test_edit_get_shows_form(self, admin_client, org, viewer_role):
489 response = admin_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
490 assert response.status_code == 200
491 content = response.content.decode()
492 assert "Edit Viewer" in content
493 assert "Permissions" in content
494
495 def test_edit_updates_role(self, admin_client, org, viewer_role):
496 response = admin_client.post(
497 reverse("organization:role_edit", kwargs={"slug": "viewer"}),
498 {"name": "Viewer Updated", "description": "Updated description"},
499 )
500 assert response.status_code == 302
501 viewer_role.refresh_from_db()
502 assert viewer_role.name == "Viewer Updated"
503 assert viewer_role.description == "Updated description"
504
505 def test_edit_updates_permissions(self, admin_client, org, viewer_role):
506 add_perm = Permission.objects.get(content_type__app_label="organization", codename="add_organization")
507 response = admin_client.post(
508 reverse("organization:role_edit", kwargs={"slug": "viewer"}),
509 {"name": "Viewer", "description": "Updated", "permissions": [add_perm.pk]},
510 )
511 assert response.status_code == 302
512 viewer_role.refresh_from_db()
513 assert list(viewer_role.permissions.values_list("pk", flat=True)) == [add_perm.pk]
514
515 def test_edit_clears_permissions(self, admin_client, org, viewer_role):
516 assert viewer_role.permissions.count() > 0
517 response = admin_client.post(
518 reverse("organization:role_edit", kwargs={"slug": "viewer"}),
519 {"name": "Viewer", "description": "No perms now"},
520 )
521 assert response.status_code == 302
522 viewer_role.refresh_from_db()
523 assert viewer_role.permissions.count() == 0
524
525 def test_edit_pre_populates_permissions(self, admin_client, org, viewer_role):
526 response = admin_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
527 assert response.status_code == 200
528 content = response.content.decode()
529 assert "checked" in content
530
531 def test_edit_sets_updated_by(self, admin_client, org, viewer_role, admin_user):
532 response = admin_client.post(
533 reverse("organization:role_edit", kwargs={"slug": "viewer"}),
534 {"name": "Viewer", "description": "Audit check"},
535 )
536 assert response.status_code == 302
537 viewer_role.refresh_from_db()
538 assert viewer_role.updated_by == admin_user
539
540 def test_edit_denied_for_viewer(self, viewer_client, org, viewer_role):
541 response = viewer_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
542 assert response.status_code == 403
543
544 def test_edit_denied_for_no_perm(self, no_perm_client, org, viewer_role):
545 response = no_perm_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
546 assert response.status_code == 403
547
548 def test_edit_denied_for_anon(self, client, org, viewer_role):
549 response = client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
550 assert response.status_code == 302 # redirect to login
551
552 def test_edit_404_for_deleted_role(self, admin_client, org, viewer_role, admin_user):
553 viewer_role.soft_delete(user=admin_user)
554 response = admin_client.get(reverse("organization:role_edit", kwargs={"slug": "viewer"}))
555 assert response.status_code == 404
556
557 def test_edit_404_for_missing_role(self, admin_client, org):
558 response = admin_client.get(reverse("organization:role_edit", kwargs={"slug": "nonexistent"}))
559 assert response.status_code == 404
560
561
562 # --- role_delete view ---
563
564
565 @pytest.mark.django_db
566 class TestRoleDeleteView:
567 def test_delete_get_shows_confirmation(self, admin_client, org, viewer_role):
568 response = admin_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
569 assert response.status_code == 200
570 content = response.content.decode()
571 assert "Delete Role" in content
572 assert "Viewer" in content
573
574 def test_delete_soft_deletes_role(self, admin_client, org, viewer_role):
575 response = admin_client.post(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
576 assert response.status_code == 302
577 viewer_role.refresh_from_db()
578 assert viewer_role.deleted_at is not None
579
580 def test_delete_blocked_when_members_assigned(self, admin_client, org, viewer_role, target_user):
581 membership = OrganizationMember.objects.get(member=target_user, organization=org)
582 membership.role = viewer_role
583 membership.save()
584
585 response = admin_client.post(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
586 assert response.status_code == 302 # redirects back to detail
587 viewer_role.refresh_from_db()
588 assert viewer_role.deleted_at is None # not deleted
589
590 def test_delete_shows_warning_for_members(self, admin_client, org, viewer_role, target_user):
591 membership = OrganizationMember.objects.get(member=target_user, organization=org)
592 membership.role = viewer_role
593 membership.save()
594
595 response = admin_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
596 assert response.status_code == 200
597 content = response.content.decode()
598 assert "active member" in content
599 assert "targetuser" in content
600
601 def test_delete_denied_for_viewer(self, viewer_client, org, viewer_role):
602 response = viewer_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
603 assert response.status_code == 403
604
605 def test_delete_denied_for_no_perm(self, no_perm_client, org, viewer_role):
606 response = no_perm_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
607 assert response.status_code == 403
608
609 def test_delete_denied_for_anon(self, client, org, viewer_role):
610 response = client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
611 assert response.status_code == 302 # redirect to login
612
613 def test_delete_404_for_deleted_role(self, admin_client, org, viewer_role, admin_user):
614 viewer_role.soft_delete(user=admin_user)
615 response = admin_client.get(reverse("organization:role_delete", kwargs={"slug": "viewer"}))
616 assert response.status_code == 404
617
618 def test_delete_htmx_returns_redirect_header(self, admin_client, org, developer_role):
619 response = admin_client.post(
620 reverse("organization:role_delete", kwargs={"slug": "developer"}),
621 HTTP_HX_REQUEST="true",
622 )
623 assert response.status_code == 200
624 assert response.headers.get("HX-Redirect") == "/settings/roles/"
625
626
627 # --- role_list Create Role button ---
628
629
630 @pytest.mark.django_db
631 class TestRoleListCreateButton:
632 def test_create_button_shown_for_admin(self, admin_client, org, roles):
633 response = admin_client.get(reverse("organization:role_list"))
634 assert response.status_code == 200
635 assert "Create Role" in response.content.decode()
636
637 def test_create_button_hidden_for_viewer(self, viewer_client, org, roles):
638 response = viewer_client.get(reverse("organization:role_list"))
639 assert response.status_code == 200
640 assert "Create Role" not in response.content.decode()
641
642
643 # --- role_detail Edit/Delete buttons ---
644
645
646 @pytest.mark.django_db
647 class TestRoleDetailButtons:
648 def test_edit_button_shown_for_admin(self, admin_client, org, viewer_role):
649 response = admin_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"}))
650 assert response.status_code == 200
651 content = response.content.decode()
652 assert "Edit" in content
653 assert "Delete" in content
654
655 def test_edit_button_hidden_for_viewer(self, viewer_client, org, viewer_role):
656 response = viewer_client.get(reverse("organization:role_detail", kwargs={"slug": "viewer"}))
657 assert response.status_code == 200
658 content = response.content.decode()
659 assert "Edit" not in content or "role_edit" not in content
660

Keyboard Shortcuts

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