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.
Commit
01683ffbbb98d11fb89fe53546dd290be88d2a1a3df525a6d0897d4b143a5c57
Parent
078489d21aaa087…
20 files changed
+4
+227
+66
-1
+3
+69
+1
-1
+4
+192
+103
+55
+3
-3
+49
+11
-1
+98
+19
-11
+1
-1
+2
-2
+47
-1
+381
+259
~
fossil/urls.py
~
fossil/views.py
~
organization/forms.py
~
organization/urls.py
~
organization/views.py
~
templates/dashboard.html
~
templates/fossil/_project_nav.html
+
templates/fossil/explorer.html
+
templates/fossil/explorer_query.html
+
templates/fossil/partials/explorer_table.html
~
templates/includes/sidebar.html
+
templates/organization/role_confirm_delete.html
~
templates/organization/role_detail.html
+
templates/organization/role_form.html
~
templates/organization/role_list.html
~
templates/pages/page_form.html
~
templates/pages/page_list.html
~
testdata/management/commands/seed.py
+
tests/test_explorer.py
~
tests/test_roles.py
+4
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -93,6 +93,10 @@ | ||
| 93 | 93 | path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"), |
| 94 | 94 | path("branches/protect/<int:pk>/delete/", views.branch_protection_delete, name="branch_protection_delete"), |
| 95 | 95 | # Artifact Shunning |
| 96 | 96 | path("admin/shun/", views.shun_list_view, name="shun_list"), |
| 97 | 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"), | |
| 98 | 102 | ] |
| 99 | 103 |
| --- 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 |
+227
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -3969,5 +3969,232 @@ | ||
| 3969 | 3969 | messages.success(request, f"Artifact {artifact_uuid[:12]}... has been permanently shunned.") |
| 3970 | 3970 | else: |
| 3971 | 3971 | messages.error(request, f"Failed to shun artifact: {result['message']}") |
| 3972 | 3972 | |
| 3973 | 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 | + ) | |
| 3974 | 4201 |
| --- 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 |
+66
-1
| --- organization/forms.py | ||
| +++ organization/forms.py | ||
| @@ -1,7 +1,9 @@ | ||
| 1 | +import contextlib | |
| 2 | + | |
| 1 | 3 | from django import forms |
| 2 | -from django.contrib.auth.models import User | |
| 4 | +from django.contrib.auth.models import Permission, User | |
| 3 | 5 | from django.contrib.auth.password_validation import validate_password |
| 4 | 6 | from django.core.exceptions import ValidationError |
| 5 | 7 | |
| 6 | 8 | from .models import Organization, OrgRole, Team |
| 7 | 9 | |
| @@ -161,5 +163,68 @@ | ||
| 161 | 163 | p1 = cleaned_data.get("new_password1") |
| 162 | 164 | p2 = cleaned_data.get("new_password2") |
| 163 | 165 | if p1 and p2 and p1 != p2: |
| 164 | 166 | self.add_error("new_password2", "Passwords do not match.") |
| 165 | 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 | |
| 166 | 231 |
| --- 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 |
+3
| --- organization/urls.py | ||
| +++ organization/urls.py | ||
| @@ -16,12 +16,15 @@ | ||
| 16 | 16 | path("members/<str:username>/edit/", views.user_edit, name="user_edit"), |
| 17 | 17 | path("members/<str:username>/password/", views.user_password, name="user_password"), |
| 18 | 18 | path("members/<str:username>/remove/", views.member_remove, name="member_remove"), |
| 19 | 19 | # Roles |
| 20 | 20 | path("roles/", views.role_list, name="role_list"), |
| 21 | + path("roles/create/", views.role_create, name="role_create"), | |
| 21 | 22 | path("roles/initialize/", views.role_initialize, name="role_initialize"), |
| 22 | 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"), | |
| 23 | 26 | # Audit log |
| 24 | 27 | path("audit/", views.audit_log, name="audit_log"), |
| 25 | 28 | # Teams |
| 26 | 29 | path("teams/", views.team_list, name="team_list"), |
| 27 | 30 | path("teams/create/", views.team_create, name="team_create"), |
| 28 | 31 |
| --- 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 |
+69
| --- organization/views.py | ||
| +++ organization/views.py | ||
| @@ -9,10 +9,11 @@ | ||
| 9 | 9 | from core.permissions import P |
| 10 | 10 | |
| 11 | 11 | from .forms import ( |
| 12 | 12 | MemberAddForm, |
| 13 | 13 | OrganizationSettingsForm, |
| 14 | + OrgRoleForm, | |
| 14 | 15 | TeamForm, |
| 15 | 16 | TeamMemberAddForm, |
| 16 | 17 | UserCreateForm, |
| 17 | 18 | UserEditForm, |
| 18 | 19 | UserPasswordForm, |
| @@ -394,10 +395,78 @@ | ||
| 394 | 395 | request, |
| 395 | 396 | "organization/role_detail.html", |
| 396 | 397 | {"role": role, "grouped_permissions": grouped, "role_members": role_members}, |
| 397 | 398 | ) |
| 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 | + | |
| 399 | 468 | |
| 400 | 469 | @login_required |
| 401 | 470 | def audit_log(request): |
| 402 | 471 | """Unified audit log across all tracked models. Requires superuser or org admin.""" |
| 403 | 472 | import math |
| 404 | 473 |
| --- 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 |
+1
-1
| --- templates/dashboard.html | ||
| +++ templates/dashboard.html | ||
| @@ -89,11 +89,11 @@ | ||
| 89 | 89 | <p class="mt-1 text-xs text-gray-500">Organize members into teams</p> |
| 90 | 90 | </a> |
| 91 | 91 | {% endif %} |
| 92 | 92 | {% if perms.pages.view_page %} |
| 93 | 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> | |
| 94 | + <h3 class="text-sm font-semibold text-gray-100">Fossilrepo KB</h3> | |
| 95 | 95 | <p class="mt-1 text-xs text-gray-500">Guides, runbooks, documentation</p> |
| 96 | 96 | </a> |
| 97 | 97 | {% endif %} |
| 98 | 98 | {% if perms.organization.view_organization %} |
| 99 | 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 | 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">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 @@ | ||
| 41 | 41 | {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %} |
| 42 | 42 | </a> |
| 43 | 43 | <a href="{% url 'fossil:repo_settings' slug=project.slug %}" |
| 44 | 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 | 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 | |
| 46 | 50 | </a> |
| 47 | 51 | {% endif %} |
| 48 | 52 | </nav> |
| 49 | 53 | |
| 50 | 54 | ADDED templates/fossil/explorer.html |
| 51 | 55 | ADDED templates/fossil/explorer_query.html |
| 52 | 56 | 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">← 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">← 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 }} · {{ 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 }} · {{ 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"> |
+3
-3
| --- templates/includes/sidebar.html | ||
| +++ templates/includes/sidebar.html | ||
| @@ -76,21 +76,21 @@ | ||
| 76 | 76 | {% endif %} |
| 77 | 77 | </div> |
| 78 | 78 | </div> |
| 79 | 79 | {% endif %} |
| 80 | 80 | |
| 81 | - <!-- Knowledge Base section --> | |
| 81 | + <!-- Fossilrepo KB section --> | |
| 82 | 82 | {% if perms.pages.view_page %} |
| 83 | 83 | <div> |
| 84 | 84 | <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" |
| 85 | 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' : ''"> | |
| 86 | + :title="collapsed ? 'Fossilrepo KB' : ''"> | |
| 87 | 87 | <span class="flex items-center gap-2"> |
| 88 | 88 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 89 | 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 | 90 | </svg> |
| 91 | - <span x-show="!collapsed" class="truncate">Knowledge Base</span> | |
| 91 | + <span x-show="!collapsed" class="truncate">Fossilrepo KB</span> | |
| 92 | 92 | </span> |
| 93 | 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 | 94 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 95 | 95 | </svg> |
| 96 | 96 | </button> |
| 97 | 97 | |
| 98 | 98 | 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">← 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">← 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 @@ | ||
| 10 | 10 | <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between"> |
| 11 | 11 | <div> |
| 12 | 12 | <h1 class="text-2xl font-bold text-gray-100">{{ role.name }}</h1> |
| 13 | 13 | <p class="mt-1 text-sm text-gray-400">{{ role.description|default:"No description." }}</p> |
| 14 | 14 | </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"> | |
| 16 | 16 | {% if role.is_default %} |
| 17 | 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> | |
| 18 | 28 | {% endif %} |
| 19 | 29 | </div> |
| 20 | 30 | </div> |
| 21 | 31 | |
| 22 | 32 | <div class="border-t border-gray-700 px-6 py-5"> |
| 23 | 33 | |
| 24 | 34 | 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">← 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">← 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 %} |
+19
-11
| --- templates/organization/role_list.html | ||
| +++ templates/organization/role_list.html | ||
| @@ -6,21 +6,29 @@ | ||
| 6 | 6 | <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> |
| 7 | 7 | </div> |
| 8 | 8 | |
| 9 | 9 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 10 | 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 %} | |
| 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> | |
| 22 | 30 | </div> |
| 23 | 31 | |
| 24 | 32 | {% if roles %} |
| 25 | 33 | <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
| 26 | 34 | {% for role in roles %} |
| 27 | 35 |
| --- 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">← 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">← 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 |
+1
-1
| --- templates/pages/page_form.html | ||
| +++ templates/pages/page_form.html | ||
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | 2 | {% block title %}{{ title }} — Fossilrepo{% endblock %} |
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | 5 | <div class="mb-6"> |
| 6 | - <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Knowledge Base</a> | |
| 6 | + <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Fossilrepo KB</a> | |
| 7 | 7 | </div> |
| 8 | 8 | |
| 9 | 9 | <div class="mx-auto max-w-4xl"> |
| 10 | 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> |
| 11 | 11 | |
| 12 | 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">← 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">← 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 |
+2
-2
| --- templates/pages/page_list.html | ||
| +++ templates/pages/page_list.html | ||
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | -{% block title %}Knowledge Base — Fossilrepo{% endblock %} | |
| 2 | +{% block title %}Fossilrepo KB — Fossilrepo{% endblock %} | |
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | 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> | |
| 6 | + <h1 class="text-2xl font-bold text-gray-100">Fossilrepo KB</h1> | |
| 7 | 7 | {% if perms.pages.add_page %} |
| 8 | 8 | <a href="{% url 'pages:create' %}" |
| 9 | 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 | 10 | New Page |
| 11 | 11 | </a> |
| 12 | 12 |
| --- 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 |
+47
-1
| --- testdata/management/commands/seed.py | ||
| +++ testdata/management/commands/seed.py | ||
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | import logging |
| 2 | 2 | |
| 3 | 3 | from django.contrib.auth.models import Group, Permission, User |
| 4 | 4 | from django.core.management.base import BaseCommand |
| 5 | 5 | |
| 6 | -from organization.models import Organization, OrganizationMember, Team | |
| 6 | +from organization.models import Organization, OrganizationMember, OrgRole, Team | |
| 7 | 7 | from pages.models import Page |
| 8 | 8 | from projects.models import Project, ProjectTeam |
| 9 | 9 | |
| 10 | 10 | logger = logging.getLogger(__name__) |
| 11 | 11 | |
| @@ -127,6 +127,52 @@ | ||
| 127 | 127 | Page.objects.get_or_create( |
| 128 | 128 | name=pdata["name"], |
| 129 | 129 | defaults={**pdata, "organization": org, "created_by": admin_user}, |
| 130 | 130 | ) |
| 131 | 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 | + | |
| 132 | 178 | self.stdout.write(self.style.SUCCESS("Seed complete.")) |
| 133 | 179 | |
| 134 | 180 | 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 |
+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 |
| --- 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 |
+259
| --- tests/test_roles.py | ||
| +++ tests/test_roles.py | ||
| @@ -396,5 +396,264 @@ | ||
| 396 | 396 | def test_detail_shows_no_role_assigned(self, admin_client, org, target_user): |
| 397 | 397 | response = admin_client.get(reverse("organization:user_detail", kwargs={"username": "targetuser"})) |
| 398 | 398 | assert response.status_code == 200 |
| 399 | 399 | content = response.content.decode() |
| 400 | 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 | |
| 401 | 660 |
| --- 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 |