FossilRepo

Add management UI, docs, dark/light theme, and Fossil integration - Organization settings + member management (CRUD, HTMX search) - Teams with member management under organization - Projects with team-based access control (read/write/admin roles) - Org-level docs (pages app) with markdown rendering - Collapsible sidebar with tree navigation showing Fossil primitives - Dark/light theme toggle (localStorage, nav stays dark) - Light mode colors matching Django admin palette - Fossilrepo logo (ammonite spiral) - Django admin logo + section header styling - Fossil integration layer: - FossilRepository + FossilSnapshot models - FossilReader (SQLite direct reads, no network dependency) - FossilCLI wrapper for write operations - Code browser (GitHub-style with commit messages per file) - Timeline with DAG graph (multi-rail branch visualization) - Ticket list + detail views - Wiki with right sidebar navigation - Forum list + thread views - Constance settings for storage configuration - Celery tasks for metadata sync and snapshots - post_save signal for auto-creating repos - Omnibus Dockerfile: Fossil 2.24 compiled from source - 69 tests passing (org, projects, pages) - Seed data with sample teams, projects, and docs Gaps tracked in #1

anonymous 2026-04-06 03:19 trunk
Commit 46d058f09c666a83aee85a6c35c2295dd9d8e79f49c7297c8e1b2b06a0be1a41
125 files changed +1 +29 -3 +34 -2 +57 +89 +1 +104 +66 +77 +39 +86 +2 -2 +57 +106 -7 +14 +4 +31 -2 +18 +30 +1 +23 +81 +32 +18 +29 +65 +25 +9 +36 +363 +3 +513 +42 +68 +17 +426 +2 -2 +8 -1 +55 +178 +11 +145 -1 +33 +176 +11 +6 +16 +180 +15 +56 +13 +43 +29 +6 +35 +395 +39 +91 +16 +152 +15 -5 +2 -1 +12 -109 +1 -1 +1 -1 +103 -23 +27 -6 +26 +34 +33 +20 +27 +1 +16 +44 +48 +19 +30 +6 +35 +33 -59 +163 +39 +28 +30 +18 +21 +26 +32 +39 +28 +62 +39 +28 +39 +28 +28 +38 +37 +28 +37 +10 +1 +28 +8 +33 +1 +39 +28 +39 +79 -8 +81 -66
+ .scuttlebot.yaml ~ CLAUDE.md ~ Dockerfile + _old_CLAUDE.md + _old_bootstrap.md + _old_fossilrepo/__init__.py + _old_fossilrepo/cli/__init__.py + _old_fossilrepo/cli/main.py + _old_fossilrepo/server/__init__.py + _old_fossilrepo/server/config.py + _old_fossilrepo/server/manager.py + _old_fossilrepo/sync/__init__.py + _old_fossilrepo/sync/mappings.py + _old_fossilrepo/sync/mirror.py ~ auth1/forms.py + boilerworks.yaml ~ bootstrap.md ~ config/settings.py ~ config/urls.py ~ conftest.py + core/context_processors.py ~ core/permissions.py + ctl/__init__.py + ctl/main.py + docker/Caddyfile + docker/Dockerfile.fossil + docker/docker-compose.fossil.yml + docker/litestream.yml + fossil-platform/Dockerfile + fossil-platform/README.md + fossil/__init__.py + fossil/admin.py + fossil/apps.py + fossil/cli.py + fossil/migrations/0001_initial.py + fossil/migrations/__init__.py + fossil/models.py + fossil/reader.py + fossil/signals.py + fossil/tasks.py + fossil/urls.py + fossil/views.py ~ items/forms.py ~ organization/admin.py + organization/forms.py + organization/migrations/0002_historicalteam_team.py ~ organization/models.py ~ organization/tests.py + organization/urls.py + organization/views.py + pages/__init__.py + pages/admin.py + pages/apps.py + pages/forms.py + pages/migrations/0001_initial.py + pages/migrations/__init__.py + pages/models.py + pages/tests.py + pages/urls.py + pages/views.py + projects/__init__.py + projects/admin.py + projects/apps.py + projects/forms.py + projects/migrations/0001_initial.py + projects/migrations/__init__.py + projects/models.py + projects/tests.py + projects/urls.py + projects/views.py ~ pyproject.toml ~ static/admin/css/dark_theme.css + static/admin/img/logo-dark.png ~ static/img/fossilrepo-logo-dark.png ~ static/img/fossilrepo-logo-dark.svg ~ templates/admin/base_site.html ~ templates/auth1/login.html ~ templates/base.html ~ templates/dashboard.html + templates/fossil/_project_nav.html + templates/fossil/code_browser.html + templates/fossil/code_file.html + templates/fossil/forum_list.html + templates/fossil/forum_thread.html + templates/fossil/partials/file_tree.html + templates/fossil/partials/ticket_table.html + templates/fossil/partials/timeline_entries.html + templates/fossil/ticket_detail.html + templates/fossil/ticket_list.html + templates/fossil/timeline.html + templates/fossil/wiki_list.html + templates/fossil/wiki_page.html ~ templates/includes/nav.html + templates/includes/sidebar.html + templates/organization/member_add.html + templates/organization/member_confirm_remove.html + templates/organization/member_list.html + templates/organization/partials/member_table.html + templates/organization/partials/team_member_table.html + templates/organization/partials/team_table.html + templates/organization/settings.html + templates/organization/settings_form.html + templates/organization/team_confirm_delete.html + templates/organization/team_detail.html + templates/organization/team_form.html + templates/organization/team_list.html + templates/organization/team_member_add.html + templates/organization/team_member_confirm_remove.html + templates/pages/page_confirm_delete.html + templates/pages/page_detail.html + templates/pages/page_form.html + templates/pages/page_list.html + templates/pages/partials/page_table.html + templates/projects/partials/project_table.html + templates/projects/partials/project_team_table.html + templates/projects/project_confirm_delete.html + templates/projects/project_detail.html + templates/projects/project_form.html + templates/projects/project_list.html + templates/projects/project_team_add.html + templates/projects/project_team_confirm_remove.html + templates/projects/project_team_edit.html ~ testdata/management/commands/seed.py + tests/__init__.py ~ uv.lock
--- a/.scuttlebot.yaml
+++ b/.scuttlebot.yaml
@@ -0,0 +1 @@
1
+channel: fossilhub
--- a/.scuttlebot.yaml
+++ b/.scuttlebot.yaml
@@ -0,0 +1 @@
 
--- a/.scuttlebot.yaml
+++ b/.scuttlebot.yaml
@@ -0,0 +1 @@
1 channel: fossilhub
+29 -3
--- CLAUDE.md
+++ CLAUDE.md
@@ -1,11 +1,15 @@
1
-# Claude -- Fossilrepo Django + HTMX
1
+# Claude -- fossilrepo
22
33
Primary conventions doc: [`bootstrap.md`](bootstrap.md)
44
55
Read it before writing any code.
66
7
+## Project Overview
8
+
9
+fossilrepo is an omnibus-style installer for a self-hosted Fossil forge. Django+HTMX management layer wrapping Fossil SCM server infrastructure with Caddy (SSL/routing), Litestream (S3 backups), and a sync bridge to GitHub/GitLab. Open source (MIT).
10
+
711
## Stack
812
913
- **Backend**: Django 5 (Python 3.12+)
1014
- **Frontend**: HTMX 2.0 + Alpine.js 3 + Tailwind CSS (CDN)
1115
- **API**: Django views returning HTML (full pages + HTMX partials)
@@ -13,16 +17,38 @@
1317
- **Auth**: Session-based (Django native, httpOnly cookies)
1418
- **Permissions**: Group-based via `P` enum (`core/permissions.py`)
1519
- **Jobs**: Celery + Redis
1620
- **Database**: PostgreSQL 16
1721
- **Linter**: Ruff (check + format), max line length 140
22
+- **Fossil SCM**: C binary, serves repos (each repo is a single .fossil SQLite file)
23
+- **Caddy**: SSL termination + subdomain routing to Fossil instances
24
+- **Litestream**: Continuous SQLite-to-S3 replication for backups
25
+
26
+## Repository Structure
27
+
28
+```
29
+fossilrepo/
30
+├── core/ # Base models, permissions, shared utilities
31
+├── auth1/ # Authentication
32
+├── organization/ # Org/team management
33
+├── items/ # Repo item models
34
+├── config/ # Django settings
35
+├── templates/ # Django + HTMX templates
36
+├── static/ # Static assets
37
+├── docker/ # Caddy, Litestream container configs
38
+├── fossil-platform/ # Old exploration (Flask + React), kept for reference
39
+├── tests/ # pytest
40
+├── docs/ # Architecture, guides
41
+└── bootstrap.md # Project bootstrap doc -- read first
42
+```
1843
1944
## Claude-specific notes
2045
2146
- Prefer `Edit` over rewriting whole files.
2247
- Run `ruff check .` and `ruff format --check .` before committing.
23
-- Never expose integer PKs in URLs or templates — use `slug` or `guid`.
24
-- Auth check at the top of every view — use `@login_required` + `P.PERMISSION.check(request.user)`.
48
+- Never expose integer PKs in URLs or templates -- use `slug` or `guid`.
49
+- Auth check at the top of every view -- use `@login_required` + `P.PERMISSION.check(request.user)`.
2550
- Soft-delete only: call `item.soft_delete(user=request.user)`, never `.delete()`.
2651
- HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page.
2752
- CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`.
2853
- Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases.
54
+- Fossil is the source of truth; Git remotes are downstream mirrors.
2955
--- CLAUDE.md
+++ CLAUDE.md
@@ -1,11 +1,15 @@
1 # Claude -- Fossilrepo Django + HTMX
2
3 Primary conventions doc: [`bootstrap.md`](bootstrap.md)
4
5 Read it before writing any code.
6
 
 
 
 
7 ## Stack
8
9 - **Backend**: Django 5 (Python 3.12+)
10 - **Frontend**: HTMX 2.0 + Alpine.js 3 + Tailwind CSS (CDN)
11 - **API**: Django views returning HTML (full pages + HTMX partials)
@@ -13,16 +17,38 @@
13 - **Auth**: Session-based (Django native, httpOnly cookies)
14 - **Permissions**: Group-based via `P` enum (`core/permissions.py`)
15 - **Jobs**: Celery + Redis
16 - **Database**: PostgreSQL 16
17 - **Linter**: Ruff (check + format), max line length 140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
19 ## Claude-specific notes
20
21 - Prefer `Edit` over rewriting whole files.
22 - Run `ruff check .` and `ruff format --check .` before committing.
23 - Never expose integer PKs in URLs or templates — use `slug` or `guid`.
24 - Auth check at the top of every view — use `@login_required` + `P.PERMISSION.check(request.user)`.
25 - Soft-delete only: call `item.soft_delete(user=request.user)`, never `.delete()`.
26 - HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page.
27 - CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`.
28 - Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases.
 
29
--- CLAUDE.md
+++ CLAUDE.md
@@ -1,11 +1,15 @@
1 # Claude -- fossilrepo
2
3 Primary conventions doc: [`bootstrap.md`](bootstrap.md)
4
5 Read it before writing any code.
6
7 ## Project Overview
8
9 fossilrepo is an omnibus-style installer for a self-hosted Fossil forge. Django+HTMX management layer wrapping Fossil SCM server infrastructure with Caddy (SSL/routing), Litestream (S3 backups), and a sync bridge to GitHub/GitLab. Open source (MIT).
10
11 ## Stack
12
13 - **Backend**: Django 5 (Python 3.12+)
14 - **Frontend**: HTMX 2.0 + Alpine.js 3 + Tailwind CSS (CDN)
15 - **API**: Django views returning HTML (full pages + HTMX partials)
@@ -13,16 +17,38 @@
17 - **Auth**: Session-based (Django native, httpOnly cookies)
18 - **Permissions**: Group-based via `P` enum (`core/permissions.py`)
19 - **Jobs**: Celery + Redis
20 - **Database**: PostgreSQL 16
21 - **Linter**: Ruff (check + format), max line length 140
22 - **Fossil SCM**: C binary, serves repos (each repo is a single .fossil SQLite file)
23 - **Caddy**: SSL termination + subdomain routing to Fossil instances
24 - **Litestream**: Continuous SQLite-to-S3 replication for backups
25
26 ## Repository Structure
27
28 ```
29 fossilrepo/
30 ├── core/ # Base models, permissions, shared utilities
31 ├── auth1/ # Authentication
32 ├── organization/ # Org/team management
33 ├── items/ # Repo item models
34 ├── config/ # Django settings
35 ├── templates/ # Django + HTMX templates
36 ├── static/ # Static assets
37 ├── docker/ # Caddy, Litestream container configs
38 ├── fossil-platform/ # Old exploration (Flask + React), kept for reference
39 ├── tests/ # pytest
40 ├── docs/ # Architecture, guides
41 └── bootstrap.md # Project bootstrap doc -- read first
42 ```
43
44 ## Claude-specific notes
45
46 - Prefer `Edit` over rewriting whole files.
47 - Run `ruff check .` and `ruff format --check .` before committing.
48 - Never expose integer PKs in URLs or templates -- use `slug` or `guid`.
49 - Auth check at the top of every view -- use `@login_required` + `P.PERMISSION.check(request.user)`.
50 - Soft-delete only: call `item.soft_delete(user=request.user)`, never `.delete()`.
51 - HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page.
52 - CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`.
53 - Tests: pytest + real Postgres, assert against DB state. Both allowed and denied permission cases.
54 - Fossil is the source of truth; Git remotes are downstream mirrors.
55
+34 -2
--- Dockerfile
+++ Dockerfile
@@ -1,10 +1,39 @@
1
+# fossilrepo backend — Django + HTMX + Fossil binary
2
+#
3
+# Omnibus: bundles Fossil from source for repo init/management.
4
+
5
+# ── Stage 1: Build Fossil from source ──────────────────────────────────────
6
+
7
+FROM debian:bookworm-slim AS fossil-builder
8
+
9
+ARG FOSSIL_VERSION=2.24
10
+
11
+RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ build-essential curl ca-certificates zlib1g-dev libssl-dev tcl \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+WORKDIR /build
16
+RUN curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \
17
+ -o fossil.tar.gz \
18
+ && tar xzf fossil.tar.gz \
19
+ && cd fossil-src-${FOSSIL_VERSION} \
20
+ && ./configure --prefix=/usr/local --with-openssl=auto --json \
21
+ && make -j$(nproc) \
22
+ && make install
23
+
24
+# ── Stage 2: Runtime image ─────────────────────────────────────────────────
25
+
126
FROM python:3.12-slim-bookworm
227
328
RUN apt-get update && apt-get install -y --no-install-recommends \
4
- postgresql-client ca-certificates && \
5
- rm -rf /var/lib/apt/lists/*
29
+ postgresql-client ca-certificates zlib1g libssl3 \
30
+ && rm -rf /var/lib/apt/lists/*
31
+
32
+# Copy Fossil binary from builder
33
+COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
34
+RUN fossil version
635
736
RUN pip install --no-cache-dir uv
837
938
WORKDIR /app
1039
@@ -12,13 +41,16 @@
1241
RUN uv pip install --system --no-cache -r pyproject.toml
1342
1443
COPY . .
1544
1645
RUN python manage.py collectstatic --noinput 2>/dev/null || true
46
+
47
+# Create data directory for .fossil files
48
+RUN mkdir -p /data/repos /data/trash
1749
1850
ENV PYTHONUNBUFFERED=1
1951
ENV PYTHONDONTWRITEBYTECODE=1
2052
ENV DJANGO_SETTINGS_MODULE=config.settings
2153
2254
EXPOSE 8000
2355
2456
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
2557
2658
ADDED _old_CLAUDE.md
2759
ADDED _old_bootstrap.md
2860
ADDED _old_fossilrepo/__init__.py
2961
ADDED _old_fossilrepo/cli/__init__.py
3062
ADDED _old_fossilrepo/cli/main.py
3163
ADDED _old_fossilrepo/server/__init__.py
3264
ADDED _old_fossilrepo/server/config.py
3365
ADDED _old_fossilrepo/server/manager.py
3466
ADDED _old_fossilrepo/sync/__init__.py
3567
ADDED _old_fossilrepo/sync/mappings.py
3668
ADDED _old_fossilrepo/sync/mirror.py
--- Dockerfile
+++ Dockerfile
@@ -1,10 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1 FROM python:3.12-slim-bookworm
2
3 RUN apt-get update && apt-get install -y --no-install-recommends \
4 postgresql-client ca-certificates && \
5 rm -rf /var/lib/apt/lists/*
 
 
 
 
6
7 RUN pip install --no-cache-dir uv
8
9 WORKDIR /app
10
@@ -12,13 +41,16 @@
12 RUN uv pip install --system --no-cache -r pyproject.toml
13
14 COPY . .
15
16 RUN python manage.py collectstatic --noinput 2>/dev/null || true
 
 
 
17
18 ENV PYTHONUNBUFFERED=1
19 ENV PYTHONDONTWRITEBYTECODE=1
20 ENV DJANGO_SETTINGS_MODULE=config.settings
21
22 EXPOSE 8000
23
24 CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
25
26 DDED _old_CLAUDE.md
27 DDED _old_bootstrap.md
28 DDED _old_fossilrepo/__init__.py
29 DDED _old_fossilrepo/cli/__init__.py
30 DDED _old_fossilrepo/cli/main.py
31 DDED _old_fossilrepo/server/__init__.py
32 DDED _old_fossilrepo/server/config.py
33 DDED _old_fossilrepo/server/manager.py
34 DDED _old_fossilrepo/sync/__init__.py
35 DDED _old_fossilrepo/sync/mappings.py
36 DDED _old_fossilrepo/sync/mirror.py
--- Dockerfile
+++ Dockerfile
@@ -1,10 +1,39 @@
1 # fossilrepo backend — Django + HTMX + Fossil binary
2 #
3 # Omnibus: bundles Fossil from source for repo init/management.
4
5 # ── Stage 1: Build Fossil from source ──────────────────────────────────────
6
7 FROM debian:bookworm-slim AS fossil-builder
8
9 ARG FOSSIL_VERSION=2.24
10
11 RUN apt-get update && apt-get install -y --no-install-recommends \
12 build-essential curl ca-certificates zlib1g-dev libssl-dev tcl \
13 && rm -rf /var/lib/apt/lists/*
14
15 WORKDIR /build
16 RUN curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \
17 -o fossil.tar.gz \
18 && tar xzf fossil.tar.gz \
19 && cd fossil-src-${FOSSIL_VERSION} \
20 && ./configure --prefix=/usr/local --with-openssl=auto --json \
21 && make -j$(nproc) \
22 && make install
23
24 # ── Stage 2: Runtime image ─────────────────────────────────────────────────
25
26 FROM python:3.12-slim-bookworm
27
28 RUN apt-get update && apt-get install -y --no-install-recommends \
29 postgresql-client ca-certificates zlib1g libssl3 \
30 && rm -rf /var/lib/apt/lists/*
31
32 # Copy Fossil binary from builder
33 COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
34 RUN fossil version
35
36 RUN pip install --no-cache-dir uv
37
38 WORKDIR /app
39
@@ -12,13 +41,16 @@
41 RUN uv pip install --system --no-cache -r pyproject.toml
42
43 COPY . .
44
45 RUN python manage.py collectstatic --noinput 2>/dev/null || true
46
47 # Create data directory for .fossil files
48 RUN mkdir -p /data/repos /data/trash
49
50 ENV PYTHONUNBUFFERED=1
51 ENV PYTHONDONTWRITEBYTECODE=1
52 ENV DJANGO_SETTINGS_MODULE=config.settings
53
54 EXPOSE 8000
55
56 CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
57
58 DDED _old_CLAUDE.md
59 DDED _old_bootstrap.md
60 DDED _old_fossilrepo/__init__.py
61 DDED _old_fossilrepo/cli/__init__.py
62 DDED _old_fossilrepo/cli/main.py
63 DDED _old_fossilrepo/server/__init__.py
64 DDED _old_fossilrepo/server/config.py
65 DDED _old_fossilrepo/server/manager.py
66 DDED _old_fossilrepo/sync/__init__.py
67 DDED _old_fossilrepo/sync/mappings.py
68 DDED _old_fossilrepo/sync/mirror.py
--- a/_old_CLAUDE.md
+++ b/_old_CLAUDE.md
@@ -0,0 +1,57 @@
1
+# CLAUDE.md -- fossilrepo
2
+
3
+## Project Overview
4
+
5
+fossilrepo is a self-hosted Fossil SCM server infrastructure tool. It provides Docker + Caddy + Litestream hosting for Fossil repositories, a CLI wrapper around fossil commands, and a sync bridge to mirror Fossil repos to GitHub/GitLab.
6
+
7
+Open source (MIT). Part of the CONFLICT ecosystem.
8
+
9
+## Repository Structure
10
+
11
+```
12
+fossilrepo/
13
+├── fossilrepo/ # Python package
14
+│ ├── server/ # Fossil server management (Docker, Caddy, Litestream)
15
+│ │ ├── config.py # Pydantic server configuration
16
+│ │ └── manager.py # Repo lifecycle (create, delete, list)
17
+│ ├── sync/ # Fossil → Git mirror
18
+│ │ ├── mirror.py # Core sync logic (commits, tickets, wiki)
19
+│ │ └── mappings.py # Data models for Fossil↔Git mappings
20
+│ └── cli/ # Click CLI
21
+│ └── main.py # CLI entrypoint (server, repo, sync commands)
22
+├── docker/ # Container configs
23
+│ ├── Dockerfile # Fossil + Caddy + Litestream
24
+│ ├── docker-compose.yml # Local dev stack
25
+│ ├── Caddyfile # Subdomain routing
26
+│ └── litestream.yml # S3 replication
27
+├── tests/ # pytest, mirrors fossilrepo/
28
+├── docs/ # Architecture, guides
29
+├── fossil-platform/ # Old exploration (Flask + React), kept for reference
30
+├── bootstrap.md # Project bootstrap doc — read first
31
+└── AGENTS.md # Agent conventions pointer
32
+```
33
+
34
+## Key Conventions
35
+
36
+- Python 3.11+, typed with Pydantic models
37
+- Click for CLI, Rich for terminal output
38
+- Ruff for linting, pytest for testing
39
+- Fossil is the source of truth; Git remotes are downstream mirrors
40
+- Server infra: Docker + Caddy (SSL, subdomain routing) + Litestream (S3 replication)
41
+- Each repo is a single .fossil file (SQLite) — Litestream replicates it continuously
42
+
43
+## Development
44
+
45
+```bash
46
+pip install -e ".[dev]"
47
+pytest
48
+ruff check .
49
+```
50
+
51
+## CLI
52
+
53
+```bash
54
+fossilrepo server start|stop|status
55
+fossilrepo repo create|list|delete
56
+fossilrepo sync run|status
57
+```
--- a/_old_CLAUDE.md
+++ b/_old_CLAUDE.md
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_CLAUDE.md
+++ b/_old_CLAUDE.md
@@ -0,0 +1,57 @@
1 # CLAUDE.md -- fossilrepo
2
3 ## Project Overview
4
5 fossilrepo is a self-hosted Fossil SCM server infrastructure tool. It provides Docker + Caddy + Litestream hosting for Fossil repositories, a CLI wrapper around fossil commands, and a sync bridge to mirror Fossil repos to GitHub/GitLab.
6
7 Open source (MIT). Part of the CONFLICT ecosystem.
8
9 ## Repository Structure
10
11 ```
12 fossilrepo/
13 ├── fossilrepo/ # Python package
14 │ ├── server/ # Fossil server management (Docker, Caddy, Litestream)
15 │ │ ├── config.py # Pydantic server configuration
16 │ │ └── manager.py # Repo lifecycle (create, delete, list)
17 │ ├── sync/ # Fossil → Git mirror
18 │ │ ├── mirror.py # Core sync logic (commits, tickets, wiki)
19 │ │ └── mappings.py # Data models for Fossil↔Git mappings
20 │ └── cli/ # Click CLI
21 │ └── main.py # CLI entrypoint (server, repo, sync commands)
22 ├── docker/ # Container configs
23 │ ├── Dockerfile # Fossil + Caddy + Litestream
24 │ ├── docker-compose.yml # Local dev stack
25 │ ├── Caddyfile # Subdomain routing
26 │ └── litestream.yml # S3 replication
27 ├── tests/ # pytest, mirrors fossilrepo/
28 ├── docs/ # Architecture, guides
29 ├── fossil-platform/ # Old exploration (Flask + React), kept for reference
30 ├── bootstrap.md # Project bootstrap doc — read first
31 └── AGENTS.md # Agent conventions pointer
32 ```
33
34 ## Key Conventions
35
36 - Python 3.11+, typed with Pydantic models
37 - Click for CLI, Rich for terminal output
38 - Ruff for linting, pytest for testing
39 - Fossil is the source of truth; Git remotes are downstream mirrors
40 - Server infra: Docker + Caddy (SSL, subdomain routing) + Litestream (S3 replication)
41 - Each repo is a single .fossil file (SQLite) — Litestream replicates it continuously
42
43 ## Development
44
45 ```bash
46 pip install -e ".[dev]"
47 pytest
48 ruff check .
49 ```
50
51 ## CLI
52
53 ```bash
54 fossilrepo server start|stop|status
55 fossilrepo repo create|list|delete
56 fossilrepo sync run|status
57 ```
--- a/_old_bootstrap.md
+++ b/_old_bootstrap.md
@@ -0,0 +1,89 @@
1
+# fossilrepo — bootstrap
2
+
3
+Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups — all powered by Fossil SCM.
4
+
5
+Think GitLab Omnibus, but for Fossil.
6
+
7
+---
8
+
9
+## Why Fossil
10
+
11
+A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable — hand the file to someone and they have everything.
12
+
13
+For teams running CI agents or automation:
14
+- Agents commit, file tickets, and update the wiki through one CLI and one protocol
15
+- No API rate limits when many agents are pushing simultaneously
16
+- The `.fossil` file IS the project artifact — a self-contained archive
17
+- Litestream replicates it to S3 continuously — backup and point-in-time recovery for free
18
+
19
+Fossil also has a built-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
20
+
21
+---
22
+
23
+## What fossilrepo Does
24
+
25
+fossilrepo packages everything needed to run a production Fossil server into one installable unit:
26
+
27
+- **Fossil server** — serves all repos from a single process
28
+- **Caddy** — SSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
29
+- **Litestream** — continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
30
+- **CLI** — repo lifecycle management (create, list, delete) and sync tooling
31
+- **Sync bridge** — mirror Fossil repos to GitHub/GitLab as downstream read-only copies
32
+
33
+New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
34
+
35
+---
36
+
37
+## Architecture
38
+
39
+```
40
+fossilrepo/
41
+├── server/ # Fossil server infra — Docker, Caddy, Litestream
42
+├── sync/ # Fossil → GitHub/GitLab mirror
43
+├── cli/ # fossilrepo CLI wrapper
44
+└── docs/ # Architecture, guides
45
+```
46
+
47
+### Server Stack
48
+
49
+```
50
+Caddy (SSL termination, routing, subdomain per repo)
51
+ └── fossil server --repolist /data/repos/
52
+ └── /data/repos/
53
+ ├── projecta.fossil
54
+ ├── projectb.fossil
55
+ └── ...
56
+
57
+Litestream → S3/MinIO (continuous replication, point-in-time recovery)
58
+```
59
+
60
+One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
61
+
62
+### Sync Bridge
63
+
64
+Mirrors Fossil to GitHub/GitLab as a downstream copy. Fossil is the source of truth.
65
+
66
+Maps:
67
+- Fossil commits → Git commits
68
+- Fossil tickets → GitHub/GitLab Issues (optional, configurable)
69
+- Fossil wiki → repo docs (optional, configurable)
70
+
71
+Triggered on demand or on schedule.
72
+
73
+---
74
+
75
+## Platform Vision (fossilrepos.com)
76
+
77
+GitLab model:
78
+- **Self-hosted** — open source, run it yourself. fossilrepo is the tool.
79
+- **Managed** — fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
80
+
81
+The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite — Fossil already does the hard parts. The value is the hosting and UX polish.
82
+
83
+Not being built yet — get the self-hosted tool right first.
84
+
85
+---
86
+
87
+## License
88
+
89
+MIT.
--- a/_old_bootstrap.md
+++ b/_old_bootstrap.md
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_bootstrap.md
+++ b/_old_bootstrap.md
@@ -0,0 +1,89 @@
1 # fossilrepo — bootstrap
2
3 Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups — all powered by Fossil SCM.
4
5 Think GitLab Omnibus, but for Fossil.
6
7 ---
8
9 ## Why Fossil
10
11 A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable — hand the file to someone and they have everything.
12
13 For teams running CI agents or automation:
14 - Agents commit, file tickets, and update the wiki through one CLI and one protocol
15 - No API rate limits when many agents are pushing simultaneously
16 - The `.fossil` file IS the project artifact — a self-contained archive
17 - Litestream replicates it to S3 continuously — backup and point-in-time recovery for free
18
19 Fossil also has a built-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
20
21 ---
22
23 ## What fossilrepo Does
24
25 fossilrepo packages everything needed to run a production Fossil server into one installable unit:
26
27 - **Fossil server** — serves all repos from a single process
28 - **Caddy** — SSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
29 - **Litestream** — continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
30 - **CLI** — repo lifecycle management (create, list, delete) and sync tooling
31 - **Sync bridge** — mirror Fossil repos to GitHub/GitLab as downstream read-only copies
32
33 New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
34
35 ---
36
37 ## Architecture
38
39 ```
40 fossilrepo/
41 ├── server/ # Fossil server infra — Docker, Caddy, Litestream
42 ├── sync/ # Fossil → GitHub/GitLab mirror
43 ├── cli/ # fossilrepo CLI wrapper
44 └── docs/ # Architecture, guides
45 ```
46
47 ### Server Stack
48
49 ```
50 Caddy (SSL termination, routing, subdomain per repo)
51 └── fossil server --repolist /data/repos/
52 └── /data/repos/
53 ├── projecta.fossil
54 ├── projectb.fossil
55 └── ...
56
57 Litestream → S3/MinIO (continuous replication, point-in-time recovery)
58 ```
59
60 One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
61
62 ### Sync Bridge
63
64 Mirrors Fossil to GitHub/GitLab as a downstream copy. Fossil is the source of truth.
65
66 Maps:
67 - Fossil commits → Git commits
68 - Fossil tickets → GitHub/GitLab Issues (optional, configurable)
69 - Fossil wiki → repo docs (optional, configurable)
70
71 Triggered on demand or on schedule.
72
73 ---
74
75 ## Platform Vision (fossilrepos.com)
76
77 GitLab model:
78 - **Self-hosted** — open source, run it yourself. fossilrepo is the tool.
79 - **Managed** — fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
80
81 The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite — Fossil already does the hard parts. The value is the hosting and UX polish.
82
83 Not being built yet — get the self-hosted tool right first.
84
85 ---
86
87 ## License
88
89 MIT.
--- a/_old_fossilrepo/__init__.py
+++ b/_old_fossilrepo/__init__.py
@@ -0,0 +1 @@
1
+__version__ = "0.1.0"
--- a/_old_fossilrepo/__init__.py
+++ b/_old_fossilrepo/__init__.py
@@ -0,0 +1 @@
 
--- a/_old_fossilrepo/__init__.py
+++ b/_old_fossilrepo/__init__.py
@@ -0,0 +1 @@
1 __version__ = "0.1.0"

No diff available

--- a/_old_fossilrepo/cli/main.py
+++ b/_old_fossilrepo/cli/main.py
@@ -0,0 +1,104 @@
1
+"""fossilrepo CLI — manage Fossil servers, repos, and Git sync."""
2
+
3
+import click
4
+from rich.console import Console
5
+
6
+console = Console()
7
+
8
+
9
+@click.group()
10
+@click.version_option(package_name="fossilrepo")
11
+def cli() -> None:
12
+ """fossilrepo — self-hosted Fossil SCM infrastructure."""
13
+
14
+
15
+# ---------------------------------------------------------------------------
16
+# Server commands
17
+# ---------------------------------------------------------------------------
18
+
19
+
20
+@cli.group()
21
+def server() -> None:
22
+ """Manage the Fossil server."""
23
+
24
+
25
+@server.command()
26
+def start() -> None:
27
+ """Start the Fossil server (Docker + Caddy + Litestream)."""
28
+ console.print("[bold]Starting Fossil server...[/bold]")
29
+ raise NotImplementedError
30
+
31
+
32
+@server.command()
33
+def stop() -> None:
34
+ """Stop the Fossil server."""
35
+ console.print("[bold]Stopping Fossil server...[/bold]")
36
+ raise NotImplementedError
37
+
38
+
39
+@server.command()
40
+def status() -> None:
41
+ """Show Fossil server status."""
42
+ console.print("[bold]Server status:[/bold]")
43
+ raise NotImplementedError
44
+
45
+
46
+# ---------------------------------------------------------------------------
47
+# Repo commands
48
+# ---------------------------------------------------------------------------
49
+
50
+
51
+@cli.group()
52
+def repo() -> None:
53
+ """Manage Fossil repositories."""
54
+
55
+
56
+@repo.command()
57
+@click.argument("name")
58
+def create(name: str) -> None:
59
+ """Create a new Fossil repository."""
60
+ console.print(f"[bold]Creating repo:[/bold] {name}")
61
+ raise NotImplementedError
62
+
63
+
64
+@repo.command(name="list")
65
+def list_repos() -> None:
66
+ """List all Fossil repositories."""
67
+ raise NotImplementedError
68
+
69
+
70
+@repo.command()
71
+@click.argument("name")
72
+def delete(name: str) -> None:
73
+ """Delete a Fossil repository."""
74
+ console.print(f"[bold]Deleting repo:[/bold] {name}")
75
+ raise NotImplementedError
76
+
77
+
78
+# ---------------------------------------------------------------------------
79
+# Sync commands
80
+# ---------------------------------------------------------------------------
81
+
82
+
83
+@cli.group()
84
+def sync() -> None:
85
+ """Sync Fossil repos to GitHub/GitLab."""
86
+
87
+
88
+@sync.command()
89
+@click.argument("repo_name")
90
+@click.option("--remote", required=True, help="Git remote URL to sync to.")
91
+@click.option("--tickets/--no-tickets", default=False, help="Sync tickets as issues.")
92
+@click.option("--wiki/--no-wiki", default=False, help="Sync wiki pages.")
93
+def run(repo_name: str, remote: str, tickets: bool, wiki: bool) -> None:
94
+ """Run a sync from a Fossil repo to a Git remote."""
95
+ console.print(f"[bold]Syncing[/bold] {repo_name} -> {remote}")
96
+ raise NotImplementedError
97
+
98
+
99
+@sync.command()
100
+@click.argument("repo_name")
101
+def status(repo_name: str) -> None: # noqa: F811
102
+ """Show sync status for a repository."""
103
+ console.print(f"[bold]Sync status for:[/bold] {repo_name}")
104
+ raise NotImplementedError
--- a/_old_fossilrepo/cli/main.py
+++ b/_old_fossilrepo/cli/main.py
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/cli/main.py
+++ b/_old_fossilrepo/cli/main.py
@@ -0,0 +1,104 @@
1 """fossilrepo CLI — manage Fossil servers, repos, and Git sync."""
2
3 import click
4 from rich.console import Console
5
6 console = Console()
7
8
9 @click.group()
10 @click.version_option(package_name="fossilrepo")
11 def cli() -> None:
12 """fossilrepo — self-hosted Fossil SCM infrastructure."""
13
14
15 # ---------------------------------------------------------------------------
16 # Server commands
17 # ---------------------------------------------------------------------------
18
19
20 @cli.group()
21 def server() -> None:
22 """Manage the Fossil server."""
23
24
25 @server.command()
26 def start() -> None:
27 """Start the Fossil server (Docker + Caddy + Litestream)."""
28 console.print("[bold]Starting Fossil server...[/bold]")
29 raise NotImplementedError
30
31
32 @server.command()
33 def stop() -> None:
34 """Stop the Fossil server."""
35 console.print("[bold]Stopping Fossil server...[/bold]")
36 raise NotImplementedError
37
38
39 @server.command()
40 def status() -> None:
41 """Show Fossil server status."""
42 console.print("[bold]Server status:[/bold]")
43 raise NotImplementedError
44
45
46 # ---------------------------------------------------------------------------
47 # Repo commands
48 # ---------------------------------------------------------------------------
49
50
51 @cli.group()
52 def repo() -> None:
53 """Manage Fossil repositories."""
54
55
56 @repo.command()
57 @click.argument("name")
58 def create(name: str) -> None:
59 """Create a new Fossil repository."""
60 console.print(f"[bold]Creating repo:[/bold] {name}")
61 raise NotImplementedError
62
63
64 @repo.command(name="list")
65 def list_repos() -> None:
66 """List all Fossil repositories."""
67 raise NotImplementedError
68
69
70 @repo.command()
71 @click.argument("name")
72 def delete(name: str) -> None:
73 """Delete a Fossil repository."""
74 console.print(f"[bold]Deleting repo:[/bold] {name}")
75 raise NotImplementedError
76
77
78 # ---------------------------------------------------------------------------
79 # Sync commands
80 # ---------------------------------------------------------------------------
81
82
83 @cli.group()
84 def sync() -> None:
85 """Sync Fossil repos to GitHub/GitLab."""
86
87
88 @sync.command()
89 @click.argument("repo_name")
90 @click.option("--remote", required=True, help="Git remote URL to sync to.")
91 @click.option("--tickets/--no-tickets", default=False, help="Sync tickets as issues.")
92 @click.option("--wiki/--no-wiki", default=False, help="Sync wiki pages.")
93 def run(repo_name: str, remote: str, tickets: bool, wiki: bool) -> None:
94 """Run a sync from a Fossil repo to a Git remote."""
95 console.print(f"[bold]Syncing[/bold] {repo_name} -> {remote}")
96 raise NotImplementedError
97
98
99 @sync.command()
100 @click.argument("repo_name")
101 def status(repo_name: str) -> None: # noqa: F811
102 """Show sync status for a repository."""
103 console.print(f"[bold]Sync status for:[/bold] {repo_name}")
104 raise NotImplementedError
--- a/_old_fossilrepo/server/config.py
+++ b/_old_fossilrepo/server/config.py
@@ -0,0 +1,66 @@
1
+"""Server configuration for Fossil repository hosting."""
2
+
3
+from pathlib import Path
4
+
5
+from pydantic import Field
6
+from pydantic_settings import BaseSettings
7
+
8
+
9
+class ServerConfig(BaseSettings):
10
+ """Configuration for the Fossil server infrastructure.
11
+
12
+ Values are loaded from environment variables prefixed with FOSSILREPO_.
13
+ For example, FOSSILREPO_DATA_DIR sets data_dir.
14
+ """
15
+
16
+ model_config = {"env_prefix": "FOSSILREPO_"}
17
+
18
+ data_dir: Path = Field(
19
+ default=Path("/data/repos"),
20
+ description="Directory where .fossil repository files are stored.",
21
+ )
22
+
23
+ caddy_domain: str = Field(
24
+ default="localhost",
25
+ description="Base domain for subdomain routing (e.g., fossilrepos.io).",
26
+ )
27
+
28
+ caddy_config_path: Path = Field(
29
+ default=Path("/etc/caddy/Caddyfile"),
30
+ description="Path to the Caddy configuration file.",
31
+ )
32
+
33
+ fossil_port: int = Field(
34
+ default=8080,
35
+ description="Port the fossil server listens on.",
36
+ )
37
+
38
+ s3_bucket: str = Field(
39
+ default="",
40
+ description="S3 bucket for Litestream replication.",
41
+ )
42
+
43
+ s3_endpoint: str = Field(
44
+ default="",
45
+ description="S3-compatible endpoint URL (for MinIO, R2, etc.).",
46
+ )
47
+
48
+ s3_access_key_id: str = Field(
49
+ default="",
50
+ description="AWS access key ID for S3 replication.",
51
+ )
52
+
53
+ s3_secret_access_key: str = Field(
54
+ default="",
55
+ description="AWS secret access key for S3 replication.",
56
+ )
57
+
58
+ s3_region: str = Field(
59
+ default="us-east-1",
60
+ description="AWS region for S3 bucket.",
61
+ )
62
+
63
+ litestream_config_path: Path = Field(
64
+ default=Path("/etc/litestream.yml"),
65
+ description="Path to the Litestream configuration file.",
66
+ )
--- a/_old_fossilrepo/server/config.py
+++ b/_old_fossilrepo/server/config.py
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/server/config.py
+++ b/_old_fossilrepo/server/config.py
@@ -0,0 +1,66 @@
1 """Server configuration for Fossil repository hosting."""
2
3 from pathlib import Path
4
5 from pydantic import Field
6 from pydantic_settings import BaseSettings
7
8
9 class ServerConfig(BaseSettings):
10 """Configuration for the Fossil server infrastructure.
11
12 Values are loaded from environment variables prefixed with FOSSILREPO_.
13 For example, FOSSILREPO_DATA_DIR sets data_dir.
14 """
15
16 model_config = {"env_prefix": "FOSSILREPO_"}
17
18 data_dir: Path = Field(
19 default=Path("/data/repos"),
20 description="Directory where .fossil repository files are stored.",
21 )
22
23 caddy_domain: str = Field(
24 default="localhost",
25 description="Base domain for subdomain routing (e.g., fossilrepos.io).",
26 )
27
28 caddy_config_path: Path = Field(
29 default=Path("/etc/caddy/Caddyfile"),
30 description="Path to the Caddy configuration file.",
31 )
32
33 fossil_port: int = Field(
34 default=8080,
35 description="Port the fossil server listens on.",
36 )
37
38 s3_bucket: str = Field(
39 default="",
40 description="S3 bucket for Litestream replication.",
41 )
42
43 s3_endpoint: str = Field(
44 default="",
45 description="S3-compatible endpoint URL (for MinIO, R2, etc.).",
46 )
47
48 s3_access_key_id: str = Field(
49 default="",
50 description="AWS access key ID for S3 replication.",
51 )
52
53 s3_secret_access_key: str = Field(
54 default="",
55 description="AWS secret access key for S3 replication.",
56 )
57
58 s3_region: str = Field(
59 default="us-east-1",
60 description="AWS region for S3 bucket.",
61 )
62
63 litestream_config_path: Path = Field(
64 default=Path("/etc/litestream.yml"),
65 description="Path to the Litestream configuration file.",
66 )
--- a/_old_fossilrepo/server/manager.py
+++ b/_old_fossilrepo/server/manager.py
@@ -0,0 +1,77 @@
1
+"""Fossil repository management — create, delete, list, inspect repos."""
2
+
3
+from pathlib import Path
4
+
5
+from fossilrepo.server.config import ServerConfig
6
+
7
+
8
+class RepoInfo:
9
+ """Information about a single Fossil repository."""
10
+
11
+ def __init__(self, name: str, path: Path, size_bytes: int) -> None:
12
+ self.name = name
13
+ self.path = path
14
+ self.size_bytes = size_bytes
15
+
16
+
17
+class FossilRepoManager:
18
+ """Manages Fossil repositories on the server.
19
+
20
+ Handles repo lifecycle: creation via `fossil init`, deletion (soft — moves
21
+ to trash), listing, and metadata inspection. Coordinates with Litestream
22
+ for S3 replication of new repos.
23
+ """
24
+
25
+ def __init__(self, config: ServerConfig | None = None) -> None:
26
+ self.config = config or ServerConfig()
27
+
28
+ def create_repo(self, name: str) -> RepoInfo:
29
+ """Create a new Fossil repository.
30
+
31
+ Runs `fossil init` to create the .fossil file in the data directory,
32
+ registers the repo with Caddy for subdomain routing, and ensures
33
+ Litestream picks up the new file for replication.
34
+
35
+ Args:
36
+ name: Repository name. Used as the subdomain and filename.
37
+
38
+ Returns:
39
+ RepoInfo for the newly created repository.
40
+ """
41
+ raise NotImplementedError
42
+
43
+ def delete_repo(self, name: str) -> None:
44
+ """Soft-delete a Fossil repository.
45
+
46
+ Moves the .fossil file to a trash directory rather than deleting it.
47
+ Removes the Caddy subdomain route. Litestream retains the S3 replica.
48
+
49
+ Args:
50
+ name: Repository name to delete.
51
+ """
52
+ raise NotImplementedError
53
+
54
+ def list_repos(self) -> list[RepoInfo]:
55
+ """List all active Fossil repositories.
56
+
57
+ Scans the data directory for .fossil files and returns metadata
58
+ for each.
59
+
60
+ Returns:
61
+ List of RepoInfo objects for all active repositories.
62
+ """
63
+ raise NotImplementedError
64
+
65
+ def get_repo_info(self, name: str) -> RepoInfo:
66
+ """Get detailed information about a specific repository.
67
+
68
+ Args:
69
+ name: Repository name to inspect.
70
+
71
+ Returns:
72
+ RepoInfo with metadata about the repository.
73
+
74
+ Raises:
75
+ FileNotFoundError: If the repository does not exist.
76
+ """
77
+ raise NotImplementedError
--- a/_old_fossilrepo/server/manager.py
+++ b/_old_fossilrepo/server/manager.py
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/server/manager.py
+++ b/_old_fossilrepo/server/manager.py
@@ -0,0 +1,77 @@
1 """Fossil repository management — create, delete, list, inspect repos."""
2
3 from pathlib import Path
4
5 from fossilrepo.server.config import ServerConfig
6
7
8 class RepoInfo:
9 """Information about a single Fossil repository."""
10
11 def __init__(self, name: str, path: Path, size_bytes: int) -> None:
12 self.name = name
13 self.path = path
14 self.size_bytes = size_bytes
15
16
17 class FossilRepoManager:
18 """Manages Fossil repositories on the server.
19
20 Handles repo lifecycle: creation via `fossil init`, deletion (soft — moves
21 to trash), listing, and metadata inspection. Coordinates with Litestream
22 for S3 replication of new repos.
23 """
24
25 def __init__(self, config: ServerConfig | None = None) -> None:
26 self.config = config or ServerConfig()
27
28 def create_repo(self, name: str) -> RepoInfo:
29 """Create a new Fossil repository.
30
31 Runs `fossil init` to create the .fossil file in the data directory,
32 registers the repo with Caddy for subdomain routing, and ensures
33 Litestream picks up the new file for replication.
34
35 Args:
36 name: Repository name. Used as the subdomain and filename.
37
38 Returns:
39 RepoInfo for the newly created repository.
40 """
41 raise NotImplementedError
42
43 def delete_repo(self, name: str) -> None:
44 """Soft-delete a Fossil repository.
45
46 Moves the .fossil file to a trash directory rather than deleting it.
47 Removes the Caddy subdomain route. Litestream retains the S3 replica.
48
49 Args:
50 name: Repository name to delete.
51 """
52 raise NotImplementedError
53
54 def list_repos(self) -> list[RepoInfo]:
55 """List all active Fossil repositories.
56
57 Scans the data directory for .fossil files and returns metadata
58 for each.
59
60 Returns:
61 List of RepoInfo objects for all active repositories.
62 """
63 raise NotImplementedError
64
65 def get_repo_info(self, name: str) -> RepoInfo:
66 """Get detailed information about a specific repository.
67
68 Args:
69 name: Repository name to inspect.
70
71 Returns:
72 RepoInfo with metadata about the repository.
73
74 Raises:
75 FileNotFoundError: If the repository does not exist.
76 """
77 raise NotImplementedError

No diff available

--- a/_old_fossilrepo/sync/mappings.py
+++ b/_old_fossilrepo/sync/mappings.py
@@ -0,0 +1,39 @@
1
+"""Data models for Fossil-to-Git sync mappings."""
2
+
3
+from datetime import datetime
4
+
5
+from pydantic import BaseModel, Field
6
+
7
+
8
+class CommitMapping(BaseModel):
9
+ """Maps a Fossil checkin to a Git commit."""
10
+
11
+ fossil_hash: str = Field(description="Fossil checkin hash (SHA1).")
12
+ git_sha: str = Field(description="Corresponding Git commit SHA.")
13
+ timestamp: datetime = Field(description="Commit timestamp.")
14
+ message: str = Field(description="Commit message.")
15
+ author: str = Field(description="Author name.")
16
+
17
+
18
+class TicketMapping(BaseModel):
19
+ """Maps a Fossil ticket to a GitHub/GitLab issue."""
20
+
21
+ fossil_ticket_id: str = Field(description="Fossil ticket UUID.")
22
+ remote_issue_number: int = Field(description="GitHub/GitLab issue number.")
23
+ remote_issue_url: str = Field(description="URL to the remote issue.")
24
+ title: str = Field(description="Ticket/issue title.")
25
+ status: str = Field(description="Current status (open, closed, etc.).")
26
+ last_synced: datetime = Field(description="Timestamp of last sync.")
27
+
28
+
29
+class WikiMapping(BaseModel):
30
+ """Maps a Fossil wiki page to a remote doc/wiki page."""
31
+
32
+ fossil_page_name: str = Field(description="Fossil wiki page name.")
33
+ remote_path: str = Field(
34
+ description="Path in the remote repo (e.g., docs/page.md) or wiki URL."
35
+ )
36
+ last_synced: datetime = Field(description="Timestamp of last sync.")
37
+ content_hash: str = Field(
38
+ description="Hash of the content at last sync, for change detection."
39
+ )
--- a/_old_fossilrepo/sync/mappings.py
+++ b/_old_fossilrepo/sync/mappings.py
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/sync/mappings.py
+++ b/_old_fossilrepo/sync/mappings.py
@@ -0,0 +1,39 @@
1 """Data models for Fossil-to-Git sync mappings."""
2
3 from datetime import datetime
4
5 from pydantic import BaseModel, Field
6
7
8 class CommitMapping(BaseModel):
9 """Maps a Fossil checkin to a Git commit."""
10
11 fossil_hash: str = Field(description="Fossil checkin hash (SHA1).")
12 git_sha: str = Field(description="Corresponding Git commit SHA.")
13 timestamp: datetime = Field(description="Commit timestamp.")
14 message: str = Field(description="Commit message.")
15 author: str = Field(description="Author name.")
16
17
18 class TicketMapping(BaseModel):
19 """Maps a Fossil ticket to a GitHub/GitLab issue."""
20
21 fossil_ticket_id: str = Field(description="Fossil ticket UUID.")
22 remote_issue_number: int = Field(description="GitHub/GitLab issue number.")
23 remote_issue_url: str = Field(description="URL to the remote issue.")
24 title: str = Field(description="Ticket/issue title.")
25 status: str = Field(description="Current status (open, closed, etc.).")
26 last_synced: datetime = Field(description="Timestamp of last sync.")
27
28
29 class WikiMapping(BaseModel):
30 """Maps a Fossil wiki page to a remote doc/wiki page."""
31
32 fossil_page_name: str = Field(description="Fossil wiki page name.")
33 remote_path: str = Field(
34 description="Path in the remote repo (e.g., docs/page.md) or wiki URL."
35 )
36 last_synced: datetime = Field(description="Timestamp of last sync.")
37 content_hash: str = Field(
38 description="Hash of the content at last sync, for change detection."
39 )
--- a/_old_fossilrepo/sync/mirror.py
+++ b/_old_fossilrepo/sync/mirror.py
@@ -0,0 +1,86 @@
1
+"""Fossil-to-Git mirror — sync commits, tickets, and wiki to GitHub/GitLab."""
2
+
3
+from pathlib import Path
4
+
5
+from fossilrepo.sync.mappings import CommitMapping, TicketMapping, WikiMapping
6
+
7
+
8
+class FossilMirror:
9
+ """Mirrors a Fossil repository to a Git remote (GitHub or GitLab).
10
+
11
+ Fossil is the source of truth. The Git remote is a downstream mirror
12
+ for ecosystem visibility. Syncs commits, optionally maps tickets to
13
+ issues and wiki pages to docs.
14
+ """
15
+
16
+ def __init__(self, fossil_path: Path, remote_url: str) -> None:
17
+ self.fossil_path = fossil_path
18
+ self.remote_url = remote_url
19
+
20
+ def sync_to_github(
21
+ self,
22
+ *,
23
+ include_tickets: bool = False,
24
+ include_wiki: bool = False,
25
+ ) -> None:
26
+ """Run a full sync to a GitHub repository.
27
+
28
+ Exports Fossil commits to Git format and pushes to the GitHub remote.
29
+ Optionally syncs tickets as GitHub Issues and wiki as repo docs.
30
+
31
+ Args:
32
+ include_tickets: If True, map Fossil tickets to GitHub Issues.
33
+ include_wiki: If True, export Fossil wiki pages to repo docs.
34
+ """
35
+ raise NotImplementedError
36
+
37
+ def sync_to_gitlab(
38
+ self,
39
+ *,
40
+ include_tickets: bool = False,
41
+ include_wiki: bool = False,
42
+ ) -> None:
43
+ """Run a full sync to a GitLab repository.
44
+
45
+ Exports Fossil commits to Git format and pushes to the GitLab remote.
46
+ Optionally syncs tickets as GitLab Issues and wiki pages.
47
+
48
+ Args:
49
+ include_tickets: If True, map Fossil tickets to GitLab Issues.
50
+ include_wiki: If True, export Fossil wiki pages to GitLab wiki.
51
+ """
52
+ raise NotImplementedError
53
+
54
+ def sync_commits(self) -> list[CommitMapping]:
55
+ """Sync Fossil commits to the Git remote.
56
+
57
+ Exports the Fossil timeline as Git commits and pushes to the
58
+ configured remote. Returns a mapping of Fossil checkin hashes
59
+ to Git commit SHAs.
60
+
61
+ Returns:
62
+ List of CommitMapping objects for each synced commit.
63
+ """
64
+ raise NotImplementedError
65
+
66
+ def sync_tickets(self) -> list[TicketMapping]:
67
+ """Sync Fossil tickets to the remote issue tracker.
68
+
69
+ Maps Fossil ticket fields to GitHub/GitLab issue fields. Creates
70
+ new issues for new tickets, updates existing ones.
71
+
72
+ Returns:
73
+ List of TicketMapping objects for each synced ticket.
74
+ """
75
+ raise NotImplementedError
76
+
77
+ def sync_wiki(self) -> list[WikiMapping]:
78
+ """Sync Fossil wiki pages to the remote.
79
+
80
+ Exports Fossil wiki pages as Markdown files. For GitHub, these go
81
+ into a docs/ directory. For GitLab, they go to the project wiki.
82
+
83
+ Returns:
84
+ List of WikiMapping objects for each synced page.
85
+ """
86
+ raise NotImplementedError
--- a/_old_fossilrepo/sync/mirror.py
+++ b/_old_fossilrepo/sync/mirror.py
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/sync/mirror.py
+++ b/_old_fossilrepo/sync/mirror.py
@@ -0,0 +1,86 @@
1 """Fossil-to-Git mirror — sync commits, tickets, and wiki to GitHub/GitLab."""
2
3 from pathlib import Path
4
5 from fossilrepo.sync.mappings import CommitMapping, TicketMapping, WikiMapping
6
7
8 class FossilMirror:
9 """Mirrors a Fossil repository to a Git remote (GitHub or GitLab).
10
11 Fossil is the source of truth. The Git remote is a downstream mirror
12 for ecosystem visibility. Syncs commits, optionally maps tickets to
13 issues and wiki pages to docs.
14 """
15
16 def __init__(self, fossil_path: Path, remote_url: str) -> None:
17 self.fossil_path = fossil_path
18 self.remote_url = remote_url
19
20 def sync_to_github(
21 self,
22 *,
23 include_tickets: bool = False,
24 include_wiki: bool = False,
25 ) -> None:
26 """Run a full sync to a GitHub repository.
27
28 Exports Fossil commits to Git format and pushes to the GitHub remote.
29 Optionally syncs tickets as GitHub Issues and wiki as repo docs.
30
31 Args:
32 include_tickets: If True, map Fossil tickets to GitHub Issues.
33 include_wiki: If True, export Fossil wiki pages to repo docs.
34 """
35 raise NotImplementedError
36
37 def sync_to_gitlab(
38 self,
39 *,
40 include_tickets: bool = False,
41 include_wiki: bool = False,
42 ) -> None:
43 """Run a full sync to a GitLab repository.
44
45 Exports Fossil commits to Git format and pushes to the GitLab remote.
46 Optionally syncs tickets as GitLab Issues and wiki pages.
47
48 Args:
49 include_tickets: If True, map Fossil tickets to GitLab Issues.
50 include_wiki: If True, export Fossil wiki pages to GitLab wiki.
51 """
52 raise NotImplementedError
53
54 def sync_commits(self) -> list[CommitMapping]:
55 """Sync Fossil commits to the Git remote.
56
57 Exports the Fossil timeline as Git commits and pushes to the
58 configured remote. Returns a mapping of Fossil checkin hashes
59 to Git commit SHAs.
60
61 Returns:
62 List of CommitMapping objects for each synced commit.
63 """
64 raise NotImplementedError
65
66 def sync_tickets(self) -> list[TicketMapping]:
67 """Sync Fossil tickets to the remote issue tracker.
68
69 Maps Fossil ticket fields to GitHub/GitLab issue fields. Creates
70 new issues for new tickets, updates existing ones.
71
72 Returns:
73 List of TicketMapping objects for each synced ticket.
74 """
75 raise NotImplementedError
76
77 def sync_wiki(self) -> list[WikiMapping]:
78 """Sync Fossil wiki pages to the remote.
79
80 Exports Fossil wiki pages as Markdown files. For GitHub, these go
81 into a docs/ directory. For GitLab, they go to the project wiki.
82
83 Returns:
84 List of WikiMapping objects for each synced page.
85 """
86 raise NotImplementedError
+2 -2
--- auth1/forms.py
+++ auth1/forms.py
@@ -4,19 +4,19 @@
44
55
class LoginForm(AuthenticationForm):
66
username = forms.CharField(
77
widget=forms.TextInput(
88
attrs={
9
- "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
9
+ "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
1010
"placeholder": "Username",
1111
"autofocus": True,
1212
}
1313
)
1414
)
1515
password = forms.CharField(
1616
widget=forms.PasswordInput(
1717
attrs={
18
- "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
18
+ "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
1919
"placeholder": "Password",
2020
}
2121
)
2222
)
2323
2424
ADDED boilerworks.yaml
--- auth1/forms.py
+++ auth1/forms.py
@@ -4,19 +4,19 @@
4
5 class LoginForm(AuthenticationForm):
6 username = forms.CharField(
7 widget=forms.TextInput(
8 attrs={
9 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
10 "placeholder": "Username",
11 "autofocus": True,
12 }
13 )
14 )
15 password = forms.CharField(
16 widget=forms.PasswordInput(
17 attrs={
18 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
19 "placeholder": "Password",
20 }
21 )
22 )
23
24 DDED boilerworks.yaml
--- auth1/forms.py
+++ auth1/forms.py
@@ -4,19 +4,19 @@
4
5 class LoginForm(AuthenticationForm):
6 username = forms.CharField(
7 widget=forms.TextInput(
8 attrs={
9 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
10 "placeholder": "Username",
11 "autofocus": True,
12 }
13 )
14 )
15 password = forms.CharField(
16 widget=forms.PasswordInput(
17 attrs={
18 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
19 "placeholder": "Password",
20 }
21 )
22 )
23
24 DDED boilerworks.yaml
--- a/boilerworks.yaml
+++ b/boilerworks.yaml
@@ -0,0 +1,57 @@
1
+# boilerworks.yaml — fossilrepo project manifest
2
+#
3
+# Run `boilerworks init` to generate the project from this file.
4
+
5
+# ── Required ──────────────────────────────────────────────────────────────────
6
+
7
+project: fossilrepo
8
+family: django-htmx
9
+size: full
10
+
11
+# ── Topology ──────────────────────────────────────────────────────────────────
12
+
13
+topology: standard
14
+
15
+# ── Cloud ─────────────────────────────────────────────────────────────────────
16
+
17
+cloud: aws
18
+region: us-east-1
19
+
20
+# ── Domain ────────────────────────────────────────────────────────────────────
21
+
22
+domain: fossilrepo.dev
23
+
24
+# ── Optional add-ons ──────────────────────────────────────────────────────────
25
+
26
+mobile: false
27
+web_presence: false
28
+
29
+# ── Compliance ────────────────────────────────────────────────────────────────
30
+
31
+compliance: []
32
+
33
+# ── Services ──────────────────────────────────────────────────────────────────
34
+
35
+services:
36
+ email: ses
37
+ storage: s3
38
+ search: null
39
+ cache: redis
40
+
41
+# ── Data ──────────────────────────────────────────────────────────────────────
42
+
43
+data:
44
+ database: postgres
45
+ migrations: true
46
+ seed_data: true
47
+
48
+# ── Testing ───────────────────────────────────────────────────────────────────
49
+
50
+testing:
51
+ e2e: null
52
+ unit: true
53
+ integration: true
54
+
55
+# ── Template versions (auto-managed, do not edit manually) ────────────────────
56
+
57
+template_versions: {}
--- a/boilerworks.yaml
+++ b/boilerworks.yaml
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks.yaml
+++ b/boilerworks.yaml
@@ -0,0 +1,57 @@
1 # boilerworks.yaml — fossilrepo project manifest
2 #
3 # Run `boilerworks init` to generate the project from this file.
4
5 # ── Required ──────────────────────────────────────────────────────────────────
6
7 project: fossilrepo
8 family: django-htmx
9 size: full
10
11 # ── Topology ──────────────────────────────────────────────────────────────────
12
13 topology: standard
14
15 # ── Cloud ─────────────────────────────────────────────────────────────────────
16
17 cloud: aws
18 region: us-east-1
19
20 # ── Domain ────────────────────────────────────────────────────────────────────
21
22 domain: fossilrepo.dev
23
24 # ── Optional add-ons ──────────────────────────────────────────────────────────
25
26 mobile: false
27 web_presence: false
28
29 # ── Compliance ────────────────────────────────────────────────────────────────
30
31 compliance: []
32
33 # ── Services ──────────────────────────────────────────────────────────────────
34
35 services:
36 email: ses
37 storage: s3
38 search: null
39 cache: redis
40
41 # ── Data ──────────────────────────────────────────────────────────────────────
42
43 data:
44 database: postgres
45 migrations: true
46 seed_data: true
47
48 # ── Testing ───────────────────────────────────────────────────────────────────
49
50 testing:
51 e2e: null
52 unit: true
53 integration: true
54
55 # ── Template versions (auto-managed, do not edit manually) ────────────────────
56
57 template_versions: {}
+106 -7
--- bootstrap.md
+++ bootstrap.md
@@ -1,10 +1,91 @@
1
-# Fossilrepo Django + HTMX -- Bootstrap
1
+# fossilrepo -- bootstrap
22
33
This is the primary conventions document. All agent shims (`CLAUDE.md`, `AGENTS.md`) point here.
44
55
An agent given this document and a business requirement should be able to generate correct, idiomatic code without exploring the codebase.
6
+
7
+---
8
+
9
+## What is fossilrepo
10
+
11
+Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups -- all powered by Fossil SCM.
12
+
13
+Think GitLab Omnibus, but for Fossil.
14
+
15
+---
16
+
17
+## Why Fossil
18
+
19
+A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable -- hand the file to someone and they have everything.
20
+
21
+For teams running CI agents or automation:
22
+- Agents commit, file tickets, and update the wiki through one CLI and one protocol
23
+- No API rate limits when many agents are pushing simultaneously
24
+- The `.fossil` file IS the project artifact -- a self-contained archive
25
+- Litestream replicates it to S3 continuously -- backup and point-in-time recovery for free
26
+
27
+Fossil also has a built-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
28
+
29
+---
30
+
31
+## What fossilrepo Does
32
+
33
+fossilrepo packages everything needed to run a production Fossil server into one installable unit:
34
+
35
+- **Fossil server** -- serves all repos from a single process
36
+- **Caddy** -- SSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
37
+- **Litestream** -- continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
38
+- **CLI** -- repo lifecycle management (create, list, delete) and sync tooling
39
+- **Sync bridge** -- mirror Fossil repos to GitHub/GitLab as downstream read-only copies
40
+
41
+New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
42
+
43
+---
44
+
45
+## Server Stack
46
+
47
+```
48
+Caddy (SSL termination, routing, subdomain per repo)
49
+ +-- fossil server --repolist /data/repos/
50
+ +-- /data/repos/
51
+ |-- projecta.fossil
52
+ |-- projectb.fossil
53
+ +-- ...
54
+
55
+Litestream -> S3/MinIO (continuous replication, point-in-time recovery)
56
+```
57
+
58
+One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
59
+
60
+### Sync Bridge
61
+
62
+Mirrors Fossil to GitHub/GitLab as a downstream copy. Fossil is the source of truth.
63
+
64
+Maps:
65
+- Fossil commits -> Git commits
66
+- Fossil tickets -> GitHub/GitLab Issues (optional, configurable)
67
+- Fossil wiki -> repo docs (optional, configurable)
68
+
69
+Triggered on demand or on schedule.
70
+
71
+---
72
+
73
+## Architecture
74
+
75
+```
76
+fossilrepo/
77
+|-- config/ # Django settings, URLs, Celery
78
+|-- core/ # Base models, permissions, middleware
79
+|-- auth1/ # Session-based auth
80
+|-- organization/ # Org + member management
81
+|-- items/ # Example CRUD app (reference only)
82
+|-- docker/ # Fossil-specific: Caddyfile, litestream.yml
83
+|-- templates/ # HTMX templates
84
+|-- _old_fossilrepo/ # Original server/sync/cli code (being ported)
85
++-- docs/ # Architecture guides
86
+```
687
788
---
889
990
## What's Already Built
1091
@@ -29,11 +110,11 @@
29110
|---|---|
30111
| `config` | Django settings, URLs, Celery configuration |
31112
| `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware |
32113
| `auth1` | Session-based authentication: login/logout views with rate limiting |
33114
| `organization` | Organization + OrganizationMember models |
34
-| `items` | Example CRUD domain demonstrating all patterns |
115
+| `items` | Example CRUD domain demonstrating all patterns (reference only -- new Fossil-specific apps will replace this as the primary domain) |
35116
| `testdata` | `seed` management command for development data |
36117
37118
---
38119
39120
## Conventions
@@ -40,20 +121,20 @@
40121
41122
### Models
42123
43124
All business models inherit from one of:
44125
45
-**`Tracking`** (abstract) — audit trails:
126
+**`Tracking`** (abstract) -- audit trails:
46127
```python
47128
from core.models import Tracking
48129
49130
class Invoice(Tracking):
50131
amount = models.DecimalField(...)
51132
```
52133
Provides: `version` (auto-increments), `created_at/by`, `updated_at/by`, `deleted_at/by`, `history` (simple_history).
53134
54
-**`BaseCoreModel(Tracking)`** (abstract) — named entities:
135
+**`BaseCoreModel(Tracking)`** (abstract) -- named entities:
55136
```python
56137
from core.models import BaseCoreModel
57138
58139
class Item(BaseCoreModel):
59140
price = models.DecimalField(...)
@@ -131,13 +212,13 @@
131212
132213
---
133214
134215
### Templates
135216
136
-- `base.html` — layout with HTMX, Alpine.js, Tailwind CSS, CSRF injection, messages
137
-- `includes/nav.html` — navigation bar with permission guards
138
-- `{app}/partials/*.html` — HTMX partial templates (no `{% extends %}`)
217
+- `base.html` -- layout with HTMX, Alpine.js, Tailwind CSS, CSRF injection, messages
218
+- `includes/nav.html` -- navigation bar with permission guards
219
+- `{app}/partials/*.html` -- HTMX partial templates (no `{% extends %}`)
139220
- CSRF token sent with all HTMX requests via `htmx:configRequest` event
140221
141222
Alpine.js patterns for client-side interactivity:
142223
```html
143224
<div x-data="{ open: false }">
@@ -239,5 +320,23 @@
239320
make lint # Run Ruff check + format
240321
make superuser # Create Django superuser
241322
make shell # Shell into container
242323
make logs # Tail Django logs
243324
```
325
+
326
+---
327
+
328
+## Platform Vision (fossilrepos.com)
329
+
330
+GitLab model:
331
+- **Self-hosted** -- open source, run it yourself. fossilrepo is the tool.
332
+- **Managed** -- fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
333
+
334
+The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite -- Fossil already does the hard parts. The value is the hosting and UX polish.
335
+
336
+Not being built yet -- get the self-hosted tool right first.
337
+
338
+---
339
+
340
+## License
341
+
342
+MIT.
244343
--- bootstrap.md
+++ bootstrap.md
@@ -1,10 +1,91 @@
1 # Fossilrepo Django + HTMX -- Bootstrap
2
3 This is the primary conventions document. All agent shims (`CLAUDE.md`, `AGENTS.md`) point here.
4
5 An agent given this document and a business requirement should be able to generate correct, idiomatic code without exploring the codebase.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
7 ---
8
9 ## What's Already Built
10
@@ -29,11 +110,11 @@
29 |---|---|
30 | `config` | Django settings, URLs, Celery configuration |
31 | `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware |
32 | `auth1` | Session-based authentication: login/logout views with rate limiting |
33 | `organization` | Organization + OrganizationMember models |
34 | `items` | Example CRUD domain demonstrating all patterns |
35 | `testdata` | `seed` management command for development data |
36
37 ---
38
39 ## Conventions
@@ -40,20 +121,20 @@
40
41 ### Models
42
43 All business models inherit from one of:
44
45 **`Tracking`** (abstract) — audit trails:
46 ```python
47 from core.models import Tracking
48
49 class Invoice(Tracking):
50 amount = models.DecimalField(...)
51 ```
52 Provides: `version` (auto-increments), `created_at/by`, `updated_at/by`, `deleted_at/by`, `history` (simple_history).
53
54 **`BaseCoreModel(Tracking)`** (abstract) — named entities:
55 ```python
56 from core.models import BaseCoreModel
57
58 class Item(BaseCoreModel):
59 price = models.DecimalField(...)
@@ -131,13 +212,13 @@
131
132 ---
133
134 ### Templates
135
136 - `base.html` — layout with HTMX, Alpine.js, Tailwind CSS, CSRF injection, messages
137 - `includes/nav.html` — navigation bar with permission guards
138 - `{app}/partials/*.html` — HTMX partial templates (no `{% extends %}`)
139 - CSRF token sent with all HTMX requests via `htmx:configRequest` event
140
141 Alpine.js patterns for client-side interactivity:
142 ```html
143 <div x-data="{ open: false }">
@@ -239,5 +320,23 @@
239 make lint # Run Ruff check + format
240 make superuser # Create Django superuser
241 make shell # Shell into container
242 make logs # Tail Django logs
243 ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
--- bootstrap.md
+++ bootstrap.md
@@ -1,10 +1,91 @@
1 # fossilrepo -- bootstrap
2
3 This is the primary conventions document. All agent shims (`CLAUDE.md`, `AGENTS.md`) point here.
4
5 An agent given this document and a business requirement should be able to generate correct, idiomatic code without exploring the codebase.
6
7 ---
8
9 ## What is fossilrepo
10
11 Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups -- all powered by Fossil SCM.
12
13 Think GitLab Omnibus, but for Fossil.
14
15 ---
16
17 ## Why Fossil
18
19 A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable -- hand the file to someone and they have everything.
20
21 For teams running CI agents or automation:
22 - Agents commit, file tickets, and update the wiki through one CLI and one protocol
23 - No API rate limits when many agents are pushing simultaneously
24 - The `.fossil` file IS the project artifact -- a self-contained archive
25 - Litestream replicates it to S3 continuously -- backup and point-in-time recovery for free
26
27 Fossil also has a built-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
28
29 ---
30
31 ## What fossilrepo Does
32
33 fossilrepo packages everything needed to run a production Fossil server into one installable unit:
34
35 - **Fossil server** -- serves all repos from a single process
36 - **Caddy** -- SSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
37 - **Litestream** -- continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
38 - **CLI** -- repo lifecycle management (create, list, delete) and sync tooling
39 - **Sync bridge** -- mirror Fossil repos to GitHub/GitLab as downstream read-only copies
40
41 New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
42
43 ---
44
45 ## Server Stack
46
47 ```
48 Caddy (SSL termination, routing, subdomain per repo)
49 +-- fossil server --repolist /data/repos/
50 +-- /data/repos/
51 |-- projecta.fossil
52 |-- projectb.fossil
53 +-- ...
54
55 Litestream -> S3/MinIO (continuous replication, point-in-time recovery)
56 ```
57
58 One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
59
60 ### Sync Bridge
61
62 Mirrors Fossil to GitHub/GitLab as a downstream copy. Fossil is the source of truth.
63
64 Maps:
65 - Fossil commits -> Git commits
66 - Fossil tickets -> GitHub/GitLab Issues (optional, configurable)
67 - Fossil wiki -> repo docs (optional, configurable)
68
69 Triggered on demand or on schedule.
70
71 ---
72
73 ## Architecture
74
75 ```
76 fossilrepo/
77 |-- config/ # Django settings, URLs, Celery
78 |-- core/ # Base models, permissions, middleware
79 |-- auth1/ # Session-based auth
80 |-- organization/ # Org + member management
81 |-- items/ # Example CRUD app (reference only)
82 |-- docker/ # Fossil-specific: Caddyfile, litestream.yml
83 |-- templates/ # HTMX templates
84 |-- _old_fossilrepo/ # Original server/sync/cli code (being ported)
85 +-- docs/ # Architecture guides
86 ```
87
88 ---
89
90 ## What's Already Built
91
@@ -29,11 +110,11 @@
110 |---|---|
111 | `config` | Django settings, URLs, Celery configuration |
112 | `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware |
113 | `auth1` | Session-based authentication: login/logout views with rate limiting |
114 | `organization` | Organization + OrganizationMember models |
115 | `items` | Example CRUD domain demonstrating all patterns (reference only -- new Fossil-specific apps will replace this as the primary domain) |
116 | `testdata` | `seed` management command for development data |
117
118 ---
119
120 ## Conventions
@@ -40,20 +121,20 @@
121
122 ### Models
123
124 All business models inherit from one of:
125
126 **`Tracking`** (abstract) -- audit trails:
127 ```python
128 from core.models import Tracking
129
130 class Invoice(Tracking):
131 amount = models.DecimalField(...)
132 ```
133 Provides: `version` (auto-increments), `created_at/by`, `updated_at/by`, `deleted_at/by`, `history` (simple_history).
134
135 **`BaseCoreModel(Tracking)`** (abstract) -- named entities:
136 ```python
137 from core.models import BaseCoreModel
138
139 class Item(BaseCoreModel):
140 price = models.DecimalField(...)
@@ -131,13 +212,13 @@
212
213 ---
214
215 ### Templates
216
217 - `base.html` -- layout with HTMX, Alpine.js, Tailwind CSS, CSRF injection, messages
218 - `includes/nav.html` -- navigation bar with permission guards
219 - `{app}/partials/*.html` -- HTMX partial templates (no `{% extends %}`)
220 - CSRF token sent with all HTMX requests via `htmx:configRequest` event
221
222 Alpine.js patterns for client-side interactivity:
223 ```html
224 <div x-data="{ open: false }">
@@ -239,5 +320,23 @@
320 make lint # Run Ruff check + format
321 make superuser # Create Django superuser
322 make shell # Shell into container
323 make logs # Tail Django logs
324 ```
325
326 ---
327
328 ## Platform Vision (fossilrepos.com)
329
330 GitLab model:
331 - **Self-hosted** -- open source, run it yourself. fossilrepo is the tool.
332 - **Managed** -- fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
333
334 The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite -- Fossil already does the hard parts. The value is the hosting and UX polish.
335
336 Not being built yet -- get the self-hosted tool right first.
337
338 ---
339
340 ## License
341
342 MIT.
343
--- config/settings.py
+++ config/settings.py
@@ -46,10 +46,11 @@
4646
"django.contrib.auth",
4747
"django.contrib.contenttypes",
4848
"django.contrib.sessions",
4949
"django.contrib.messages",
5050
"django.contrib.staticfiles",
51
+ "django.contrib.humanize",
5152
# Third-party
5253
"import_export",
5354
"simple_history",
5455
"django_celery_results",
5556
"django_celery_beat",
@@ -59,10 +60,13 @@
5960
# Project apps
6061
"core",
6162
"auth1",
6263
"organization",
6364
"items",
65
+ "projects",
66
+ "pages",
67
+ "fossil",
6468
"testdata",
6569
]
6670
6771
MIDDLEWARE = [
6872
"corsheaders.middleware.CorsMiddleware",
@@ -87,10 +91,11 @@
8791
"context_processors": [
8892
"django.template.context_processors.debug",
8993
"django.template.context_processors.request",
9094
"django.contrib.auth.context_processors.auth",
9195
"django.contrib.messages.context_processors.messages",
96
+ "core.context_processors.sidebar",
9297
],
9398
},
9499
},
95100
]
96101
@@ -200,10 +205,19 @@
200205
# --- Constance (runtime feature toggles) ---
201206
202207
CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend"
203208
CONSTANCE_CONFIG = {
204209
"SITE_NAME": ("Fossilrepo", "Display name for the site"),
210
+ "FOSSIL_DATA_DIR": ("/data/repos", "Directory where .fossil repository files are stored"),
211
+ "FOSSIL_STORE_IN_DB": (False, "Store binary snapshots of .fossil files via Django file storage"),
212
+ "FOSSIL_S3_TRACKING": (False, "Track S3/Litestream replication keys and versions"),
213
+ "FOSSIL_S3_BUCKET": ("", "S3 bucket name for Fossil repo replication"),
214
+ "FOSSIL_BINARY_PATH": ("fossil", "Path to the fossil binary"),
215
+}
216
+CONSTANCE_CONFIG_FIELDSETS = {
217
+ "General": ("SITE_NAME",),
218
+ "Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"),
205219
}
206220
207221
# --- Sentry ---
208222
209223
SENTRY_DSN = env_str("SENTRY_DSN")
210224
--- config/settings.py
+++ config/settings.py
@@ -46,10 +46,11 @@
46 "django.contrib.auth",
47 "django.contrib.contenttypes",
48 "django.contrib.sessions",
49 "django.contrib.messages",
50 "django.contrib.staticfiles",
 
51 # Third-party
52 "import_export",
53 "simple_history",
54 "django_celery_results",
55 "django_celery_beat",
@@ -59,10 +60,13 @@
59 # Project apps
60 "core",
61 "auth1",
62 "organization",
63 "items",
 
 
 
64 "testdata",
65 ]
66
67 MIDDLEWARE = [
68 "corsheaders.middleware.CorsMiddleware",
@@ -87,10 +91,11 @@
87 "context_processors": [
88 "django.template.context_processors.debug",
89 "django.template.context_processors.request",
90 "django.contrib.auth.context_processors.auth",
91 "django.contrib.messages.context_processors.messages",
 
92 ],
93 },
94 },
95 ]
96
@@ -200,10 +205,19 @@
200 # --- Constance (runtime feature toggles) ---
201
202 CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend"
203 CONSTANCE_CONFIG = {
204 "SITE_NAME": ("Fossilrepo", "Display name for the site"),
 
 
 
 
 
 
 
 
 
205 }
206
207 # --- Sentry ---
208
209 SENTRY_DSN = env_str("SENTRY_DSN")
210
--- config/settings.py
+++ config/settings.py
@@ -46,10 +46,11 @@
46 "django.contrib.auth",
47 "django.contrib.contenttypes",
48 "django.contrib.sessions",
49 "django.contrib.messages",
50 "django.contrib.staticfiles",
51 "django.contrib.humanize",
52 # Third-party
53 "import_export",
54 "simple_history",
55 "django_celery_results",
56 "django_celery_beat",
@@ -59,10 +60,13 @@
60 # Project apps
61 "core",
62 "auth1",
63 "organization",
64 "items",
65 "projects",
66 "pages",
67 "fossil",
68 "testdata",
69 ]
70
71 MIDDLEWARE = [
72 "corsheaders.middleware.CorsMiddleware",
@@ -87,10 +91,11 @@
91 "context_processors": [
92 "django.template.context_processors.debug",
93 "django.template.context_processors.request",
94 "django.contrib.auth.context_processors.auth",
95 "django.contrib.messages.context_processors.messages",
96 "core.context_processors.sidebar",
97 ],
98 },
99 },
100 ]
101
@@ -200,10 +205,19 @@
205 # --- Constance (runtime feature toggles) ---
206
207 CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend"
208 CONSTANCE_CONFIG = {
209 "SITE_NAME": ("Fossilrepo", "Display name for the site"),
210 "FOSSIL_DATA_DIR": ("/data/repos", "Directory where .fossil repository files are stored"),
211 "FOSSIL_STORE_IN_DB": (False, "Store binary snapshots of .fossil files via Django file storage"),
212 "FOSSIL_S3_TRACKING": (False, "Track S3/Litestream replication keys and versions"),
213 "FOSSIL_S3_BUCKET": ("", "S3 bucket name for Fossil repo replication"),
214 "FOSSIL_BINARY_PATH": ("fossil", "Path to the fossil binary"),
215 }
216 CONSTANCE_CONFIG_FIELDSETS = {
217 "General": ("SITE_NAME",),
218 "Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"),
219 }
220
221 # --- Sentry ---
222
223 SENTRY_DSN = env_str("SENTRY_DSN")
224
--- config/urls.py
+++ config/urls.py
@@ -188,9 +188,13 @@
188188
urlpatterns = [
189189
path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
190190
path("status/", status_page, name="status"),
191191
path("dashboard/", include("core.urls")),
192192
path("auth/", include("auth1.urls")),
193
+ path("settings/", include("organization.urls")),
194
+ path("projects/", include("projects.urls")),
195
+ path("projects/<slug:slug>/fossil/", include("fossil.urls")),
196
+ path("docs/", include("pages.urls")),
193197
path("items/", include("items.urls")),
194198
path("admin/", admin.site.urls),
195199
path("health/", health_check, name="health"),
196200
]
197201
--- config/urls.py
+++ config/urls.py
@@ -188,9 +188,13 @@
188 urlpatterns = [
189 path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
190 path("status/", status_page, name="status"),
191 path("dashboard/", include("core.urls")),
192 path("auth/", include("auth1.urls")),
 
 
 
 
193 path("items/", include("items.urls")),
194 path("admin/", admin.site.urls),
195 path("health/", health_check, name="health"),
196 ]
197
--- config/urls.py
+++ config/urls.py
@@ -188,9 +188,13 @@
188 urlpatterns = [
189 path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
190 path("status/", status_page, name="status"),
191 path("dashboard/", include("core.urls")),
192 path("auth/", include("auth1.urls")),
193 path("settings/", include("organization.urls")),
194 path("projects/", include("projects.urls")),
195 path("projects/<slug:slug>/fossil/", include("fossil.urls")),
196 path("docs/", include("pages.urls")),
197 path("items/", include("items.urls")),
198 path("admin/", admin.site.urls),
199 path("health/", health_check, name="health"),
200 ]
201
+31 -2
--- conftest.py
+++ conftest.py
@@ -1,9 +1,11 @@
11
import pytest
22
from django.contrib.auth.models import Group, Permission, User
33
4
-from organization.models import Organization, OrganizationMember
4
+from organization.models import Organization, OrganizationMember, Team
5
+from pages.models import Page
6
+from projects.models import Project, ProjectTeam
57
68
79
@pytest.fixture
810
def admin_user(db):
911
user = User.objects.create_superuser(username="admin", email="[email protected]", password="testpass123")
@@ -12,11 +14,14 @@
1214
1315
@pytest.fixture
1416
def viewer_user(db):
1517
user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123")
1618
group, _ = Group.objects.get_or_create(name="Viewers")
17
- view_perms = Permission.objects.filter(content_type__app_label="items", codename__startswith="view_")
19
+ view_perms = Permission.objects.filter(
20
+ content_type__app_label__in=["items", "organization", "projects", "pages"],
21
+ codename__startswith="view_",
22
+ )
1823
group.permissions.set(view_perms)
1924
user.groups.add(group)
2025
return user
2126
2227
@@ -29,10 +34,34 @@
2934
def org(db, admin_user):
3035
org = Organization.objects.create(name="Test Org", created_by=admin_user)
3136
OrganizationMember.objects.create(member=admin_user, organization=org)
3237
return org
3338
39
+
40
+@pytest.fixture
41
+def sample_team(db, org, admin_user):
42
+ team = Team.objects.create(name="Core Devs", organization=org, created_by=admin_user)
43
+ team.members.add(admin_user)
44
+ return team
45
+
46
+
47
+@pytest.fixture
48
+def sample_project(db, org, admin_user, sample_team):
49
+ project = Project.objects.create(name="Frontend App", organization=org, visibility="private", created_by=admin_user)
50
+ ProjectTeam.objects.create(project=project, team=sample_team, role="write", created_by=admin_user)
51
+ return project
52
+
53
+
54
+@pytest.fixture
55
+def sample_page(db, org, admin_user):
56
+ return Page.objects.create(
57
+ name="Getting Started",
58
+ content="# Getting Started\n\nWelcome to the docs.",
59
+ organization=org,
60
+ created_by=admin_user,
61
+ )
62
+
3463
3564
@pytest.fixture
3665
def admin_client(client, admin_user):
3766
client.login(username="admin", password="testpass123")
3867
return client
3968
4069
ADDED core/context_processors.py
--- conftest.py
+++ conftest.py
@@ -1,9 +1,11 @@
1 import pytest
2 from django.contrib.auth.models import Group, Permission, User
3
4 from organization.models import Organization, OrganizationMember
 
 
5
6
7 @pytest.fixture
8 def admin_user(db):
9 user = User.objects.create_superuser(username="admin", email="[email protected]", password="testpass123")
@@ -12,11 +14,14 @@
12
13 @pytest.fixture
14 def viewer_user(db):
15 user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123")
16 group, _ = Group.objects.get_or_create(name="Viewers")
17 view_perms = Permission.objects.filter(content_type__app_label="items", codename__startswith="view_")
 
 
 
18 group.permissions.set(view_perms)
19 user.groups.add(group)
20 return user
21
22
@@ -29,10 +34,34 @@
29 def org(db, admin_user):
30 org = Organization.objects.create(name="Test Org", created_by=admin_user)
31 OrganizationMember.objects.create(member=admin_user, organization=org)
32 return org
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
35 @pytest.fixture
36 def admin_client(client, admin_user):
37 client.login(username="admin", password="testpass123")
38 return client
39
40 DDED core/context_processors.py
--- conftest.py
+++ conftest.py
@@ -1,9 +1,11 @@
1 import pytest
2 from django.contrib.auth.models import Group, Permission, User
3
4 from organization.models import Organization, OrganizationMember, Team
5 from pages.models import Page
6 from projects.models import Project, ProjectTeam
7
8
9 @pytest.fixture
10 def admin_user(db):
11 user = User.objects.create_superuser(username="admin", email="[email protected]", password="testpass123")
@@ -12,11 +14,14 @@
14
15 @pytest.fixture
16 def viewer_user(db):
17 user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123")
18 group, _ = Group.objects.get_or_create(name="Viewers")
19 view_perms = Permission.objects.filter(
20 content_type__app_label__in=["items", "organization", "projects", "pages"],
21 codename__startswith="view_",
22 )
23 group.permissions.set(view_perms)
24 user.groups.add(group)
25 return user
26
27
@@ -29,10 +34,34 @@
34 def org(db, admin_user):
35 org = Organization.objects.create(name="Test Org", created_by=admin_user)
36 OrganizationMember.objects.create(member=admin_user, organization=org)
37 return org
38
39
40 @pytest.fixture
41 def sample_team(db, org, admin_user):
42 team = Team.objects.create(name="Core Devs", organization=org, created_by=admin_user)
43 team.members.add(admin_user)
44 return team
45
46
47 @pytest.fixture
48 def sample_project(db, org, admin_user, sample_team):
49 project = Project.objects.create(name="Frontend App", organization=org, visibility="private", created_by=admin_user)
50 ProjectTeam.objects.create(project=project, team=sample_team, role="write", created_by=admin_user)
51 return project
52
53
54 @pytest.fixture
55 def sample_page(db, org, admin_user):
56 return Page.objects.create(
57 name="Getting Started",
58 content="# Getting Started\n\nWelcome to the docs.",
59 organization=org,
60 created_by=admin_user,
61 )
62
63
64 @pytest.fixture
65 def admin_client(client, admin_user):
66 client.login(username="admin", password="testpass123")
67 return client
68
69 DDED core/context_processors.py
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -0,0 +1,18 @@
1
+from pages.models import Page
2
+from projects.models import Project
3
+
4
+
5
+def sidebar(request):
6
+ if not request.user.is_authenticated:
7
+ return {}
8
+
9
+ proje
10
+est.user.is_superuser:
11
+ )
12
+ pages = Page.objects.filter(is_published=True)
13
+ if request.user.has_perm("pages.change_page") or request.user.is_superuser:
14
+ pagesreturn {ouped
15
+ }
16
+return {
17
+ pages": pages,
18
+ }
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -0,0 +1,18 @@
1 from pages.models import Page
2 from projects.models import Project
3
4
5 def sidebar(request):
6 if not request.user.is_authenticated:
7 return {}
8
9 proje
10 est.user.is_superuser:
11 )
12 pages = Page.objects.filter(is_published=True)
13 if request.user.has_perm("pages.change_page") or request.user.is_superuser:
14 pagesreturn {ouped
15 }
16 return {
17 pages": pages,
18 }
--- core/permissions.py
+++ core/permissions.py
@@ -13,10 +13,40 @@
1313
ORGANIZATION_VIEW = "organization.view_organization"
1414
ORGANIZATION_ADD = "organization.add_organization"
1515
ORGANIZATION_CHANGE = "organization.change_organization"
1616
ORGANIZATION_DELETE = "organization.delete_organization"
1717
18
+ # Organization Members
19
+ ORGANIZATION_MEMBER_VIEW = "organization.view_organizationmember"
20
+ ORGANIZATION_MEMBER_ADD = "organization.add_organizationmember"
21
+ ORGANIZATION_MEMBER_CHANGE = "organization.change_organizationmember"
22
+ ORGANIZATION_MEMBER_DELETE = "organization.delete_organizationmember"
23
+
24
+ # Teams
25
+ TEAM_VIEW = "organization.view_team"
26
+ TEAM_ADD = "organization.add_team"
27
+ TEAM_CHANGE = "organization.change_team"
28
+ TEAM_DELETE = "organization.delete_team"
29
+
30
+ # Projects
31
+ PROJECT_VIEW = "projects.view_project"
32
+ PROJECT_ADD = "projects.add_project"
33
+ PROJECT_CHANGE = "projects.change_project"
34
+ PROJECT_DELETE = "projects.delete_project"
35
+
36
+ # Fossil
37
+ FOSSIL_VIEW = "fossil.view_fossilrepository"
38
+ FOSSIL_ADD = "fossil.add_fossilrepository"
39
+ FOSSIL_CHANGE = "fossil.change_fossilrepository"
40
+ FOSSIL_DELETE = "fossil.delete_fossilrepository"
41
+
42
+ # Pages (docs)
43
+ PAGE_VIEW = "pages.view_page"
44
+ PAGE_ADD = "pages.add_page"
45
+ PAGE_CHANGE = "pages.change_page"
46
+ PAGE_DELETE = "pages.delete_page"
47
+
1848
# Items (example domain)
1949
ITEM_VIEW = "items.view_item"
2050
ITEM_ADD = "items.add_item"
2151
ITEM_CHANGE = "items.change_item"
2252
ITEM_DELETE = "items.delete_item"
2353
2454
ADDED ctl/__init__.py
2555
ADDED ctl/main.py
2656
ADDED docker/Caddyfile
2757
ADDED docker/Dockerfile.fossil
2858
ADDED docker/docker-compose.fossil.yml
2959
ADDED docker/litestream.yml
3060
ADDED fossil-platform/Dockerfile
3161
ADDED fossil-platform/README.md
3262
ADDED fossil/__init__.py
3363
ADDED fossil/admin.py
3464
ADDED fossil/apps.py
3565
ADDED fossil/cli.py
3666
ADDED fossil/migrations/0001_initial.py
3767
ADDED fossil/migrations/__init__.py
3868
ADDED fossil/models.py
3969
ADDED fossil/reader.py
4070
ADDED fossil/signals.py
4171
ADDED fossil/tasks.py
4272
ADDED fossil/urls.py
4373
ADDED fossil/views.py
--- core/permissions.py
+++ core/permissions.py
@@ -13,10 +13,40 @@
13 ORGANIZATION_VIEW = "organization.view_organization"
14 ORGANIZATION_ADD = "organization.add_organization"
15 ORGANIZATION_CHANGE = "organization.change_organization"
16 ORGANIZATION_DELETE = "organization.delete_organization"
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18 # Items (example domain)
19 ITEM_VIEW = "items.view_item"
20 ITEM_ADD = "items.add_item"
21 ITEM_CHANGE = "items.change_item"
22 ITEM_DELETE = "items.delete_item"
23
24 DDED ctl/__init__.py
25 DDED ctl/main.py
26 DDED docker/Caddyfile
27 DDED docker/Dockerfile.fossil
28 DDED docker/docker-compose.fossil.yml
29 DDED docker/litestream.yml
30 DDED fossil-platform/Dockerfile
31 DDED fossil-platform/README.md
32 DDED fossil/__init__.py
33 DDED fossil/admin.py
34 DDED fossil/apps.py
35 DDED fossil/cli.py
36 DDED fossil/migrations/0001_initial.py
37 DDED fossil/migrations/__init__.py
38 DDED fossil/models.py
39 DDED fossil/reader.py
40 DDED fossil/signals.py
41 DDED fossil/tasks.py
42 DDED fossil/urls.py
43 DDED fossil/views.py
--- core/permissions.py
+++ core/permissions.py
@@ -13,10 +13,40 @@
13 ORGANIZATION_VIEW = "organization.view_organization"
14 ORGANIZATION_ADD = "organization.add_organization"
15 ORGANIZATION_CHANGE = "organization.change_organization"
16 ORGANIZATION_DELETE = "organization.delete_organization"
17
18 # Organization Members
19 ORGANIZATION_MEMBER_VIEW = "organization.view_organizationmember"
20 ORGANIZATION_MEMBER_ADD = "organization.add_organizationmember"
21 ORGANIZATION_MEMBER_CHANGE = "organization.change_organizationmember"
22 ORGANIZATION_MEMBER_DELETE = "organization.delete_organizationmember"
23
24 # Teams
25 TEAM_VIEW = "organization.view_team"
26 TEAM_ADD = "organization.add_team"
27 TEAM_CHANGE = "organization.change_team"
28 TEAM_DELETE = "organization.delete_team"
29
30 # Projects
31 PROJECT_VIEW = "projects.view_project"
32 PROJECT_ADD = "projects.add_project"
33 PROJECT_CHANGE = "projects.change_project"
34 PROJECT_DELETE = "projects.delete_project"
35
36 # Fossil
37 FOSSIL_VIEW = "fossil.view_fossilrepository"
38 FOSSIL_ADD = "fossil.add_fossilrepository"
39 FOSSIL_CHANGE = "fossil.change_fossilrepository"
40 FOSSIL_DELETE = "fossil.delete_fossilrepository"
41
42 # Pages (docs)
43 PAGE_VIEW = "pages.view_page"
44 PAGE_ADD = "pages.add_page"
45 PAGE_CHANGE = "pages.change_page"
46 PAGE_DELETE = "pages.delete_page"
47
48 # Items (example domain)
49 ITEM_VIEW = "items.view_item"
50 ITEM_ADD = "items.add_item"
51 ITEM_CHANGE = "items.change_item"
52 ITEM_DELETE = "items.delete_item"
53
54 DDED ctl/__init__.py
55 DDED ctl/main.py
56 DDED docker/Caddyfile
57 DDED docker/Dockerfile.fossil
58 DDED docker/docker-compose.fossil.yml
59 DDED docker/litestream.yml
60 DDED fossil-platform/Dockerfile
61 DDED fossil-platform/README.md
62 DDED fossil/__init__.py
63 DDED fossil/admin.py
64 DDED fossil/apps.py
65 DDED fossil/cli.py
66 DDED fossil/migrations/0001_initial.py
67 DDED fossil/migrations/__init__.py
68 DDED fossil/models.py
69 DDED fossil/reader.py
70 DDED fossil/signals.py
71 DDED fossil/tasks.py
72 DDED fossil/urls.py
73 DDED fossil/views.py

No diff available

--- a/ctl/main.py
+++ b/ctl/main.py
@@ -0,0 +1 @@
1
+"""deleRepo deletionyncsyncimport syfrom rich.table import Tab
--- a/ctl/main.py
+++ b/ctl/main.py
@@ -0,0 +1 @@
 
--- a/ctl/main.py
+++ b/ctl/main.py
@@ -0,0 +1 @@
1 """deleRepo deletionyncsyncimport syfrom rich.table import Tab
--- a/docker/Caddyfile
+++ b/docker/Caddyfile
@@ -0,0 +1,23 @@
1
+# fossilrepo Caddy configuration
2
+#
3
+# Routes *.{domain} subdomains to the fossil server.
4
+# Each repo gets its own subdomain: reponame.fossilrepos.io
5
+#
6
+# In production, replace {$FOSSILREPO_CADDY_DOMAIN} with your domain
7
+# or set the environment variable.
8
+
9
+# Wildcard subdomain routing to fossil server
10
+*.{$FOSSILREPO_CADDY_DOMAIN:localhost} {
11
+ # Extract repo name from subdomain
12
+ @repo host *.{$FOSSILREPO_CADDY_DOMAIN:localhost}
13
+
14
+ # Reverse proxy to fossil server
15
+ # fossil server --repolist serves all repos under /data/repos/
16
+ # and routes by the first path segment or subdomain
17
+ reverse_proxy @repo localhost:8080
18
+}
19
+
20
+# Root domain — landing page or redirect
21
+{$FOSSILREPO_CADDY_DOMAIN:localhost} {
22
+ respond "fossilrepo server running" 200
23
+}
--- a/docker/Caddyfile
+++ b/docker/Caddyfile
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/Caddyfile
+++ b/docker/Caddyfile
@@ -0,0 +1,23 @@
1 # fossilrepo Caddy configuration
2 #
3 # Routes *.{domain} subdomains to the fossil server.
4 # Each repo gets its own subdomain: reponame.fossilrepos.io
5 #
6 # In production, replace {$FOSSILREPO_CADDY_DOMAIN} with your domain
7 # or set the environment variable.
8
9 # Wildcard subdomain routing to fossil server
10 *.{$FOSSILREPO_CADDY_DOMAIN:localhost} {
11 # Extract repo name from subdomain
12 @repo host *.{$FOSSILREPO_CADDY_DOMAIN:localhost}
13
14 # Reverse proxy to fossil server
15 # fossil server --repolist serves all repos under /data/repos/
16 # and routes by the first path segment or subdomain
17 reverse_proxy @repo localhost:8080
18 }
19
20 # Root domain — landing page or redirect
21 {$FOSSILREPO_CADDY_DOMAIN:localhost} {
22 respond "fossilrepo server running" 200
23 }
--- a/docker/Dockerfile.fossil
+++ b/docker/Dockerfile.fossil
@@ -0,0 +1,81 @@
1
+# fossilrepo omnibus — Fossil + Caddy + Litestream
2
+#
3
+# Builds Fossil from source for version locking. Serves Fossil repos
4
+# with automatic SSL via Caddy and continuous S3 replication via Litestream.
5
+# Everything is compiled/pinned — no distro package dependencies at runtime.
6
+
7
+# ── Stage 1: Build Fossil from source ──────────────────────────────────────
8
+
9
+FROM debian:bookworm-slim AS fossil-builder
10
+
11
+ARG FOSSIL_VERSION=2.24
12
+
13
+RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ build-essential \
15
+ curl \
16
+ ca-certificates \
17
+ zlib1g-dev \
18
+ libssl-dev \
19
+ tcl \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+WORKDIR /build
23
+
24
+RUN curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \
25
+ -o fossil.tar.gz \
26
+ && tar xzf fossil.tar.gz \
27
+ && cd fossil-src-${FOSSIL_VERSION} \
28
+ && ./configure --prefix=/usr/local --with-openssl=auto --json \
29
+ && make -j$(nproc) \
30
+ && make install \
31
+ && fossil version
32
+
33
+# ── Stage 2: Runtime image ─────────────────────────────────────────────────
34
+
35
+FROM python:3.12-slim AS base
36
+
37
+# Version pins — change these to upgrade
38
+ARG LITESTREAM_VERSION=0.3.13
39
+ARG CADDY_VERSION=2.9
40
+
41
+# Runtime deps only (no build tools)
42
+RUN apt-get update && apt-get install -y --no-install-recommends \
43
+ zlib1g \
44
+ libssl3 \
45
+ curl \
46
+ ca-certificates \
47
+ && rm -rf /var/lib/apt/lists/*
48
+
49
+# Copy Fossil binary from builder
50
+COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
51
+
52
+# Install Caddy (pinned)
53
+RUN curl -sSL "https://caddyserver.com/api/download?os=linux&arch=amd64&version=v${CADDY_VERSION}" \
54
+ -o /usr/local/bin/caddy \
55
+ && chmod +x /usr/local/bin/caddy
56
+
57
+# Install Litestream (pinned)
58
+RUN curl -sSL "https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-amd64.tar.gz" \
59
+ | tar -xz -C /usr/local/bin/
60
+
61
+# Verify all binaries
62
+RUN fossil version && caddy version && litestream version
63
+
64
+# Create data directories
65
+RUN mkdir -p /data/repos /data/trash /etc/caddy
66
+
67
+# Copy configuration files
68
+COPY Caddyfile /etc/caddy/Caddyfile
69
+COPY litestream.yml /etc/litestream.yml
70
+
71
+# Copy and install the fossilrepo package
72
+COPY .. /app
73
+WORKDIR /app
74
+RUN pip install --no-cache-dir .
75
+
76
+# Expose ports: Caddy HTTPS (443), Caddy HTTP (80), Fossil direct (8080)
77
+EXPOSE 80 443 8080
78
+
79
+# Litestream wraps the fossil server process, replicating all .fossil
80
+# files to S3 continuously while the server runs.
81
+CMD ["litestream", "replicate", "-exec", "caddy run --config /etc/caddy/Caddyfile"]
--- a/docker/Dockerfile.fossil
+++ b/docker/Dockerfile.fossil
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/Dockerfile.fossil
+++ b/docker/Dockerfile.fossil
@@ -0,0 +1,81 @@
1 # fossilrepo omnibus — Fossil + Caddy + Litestream
2 #
3 # Builds Fossil from source for version locking. Serves Fossil repos
4 # with automatic SSL via Caddy and continuous S3 replication via Litestream.
5 # Everything is compiled/pinned — no distro package dependencies at runtime.
6
7 # ── Stage 1: Build Fossil from source ──────────────────────────────────────
8
9 FROM debian:bookworm-slim AS fossil-builder
10
11 ARG FOSSIL_VERSION=2.24
12
13 RUN apt-get update && apt-get install -y --no-install-recommends \
14 build-essential \
15 curl \
16 ca-certificates \
17 zlib1g-dev \
18 libssl-dev \
19 tcl \
20 && rm -rf /var/lib/apt/lists/*
21
22 WORKDIR /build
23
24 RUN curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \
25 -o fossil.tar.gz \
26 && tar xzf fossil.tar.gz \
27 && cd fossil-src-${FOSSIL_VERSION} \
28 && ./configure --prefix=/usr/local --with-openssl=auto --json \
29 && make -j$(nproc) \
30 && make install \
31 && fossil version
32
33 # ── Stage 2: Runtime image ─────────────────────────────────────────────────
34
35 FROM python:3.12-slim AS base
36
37 # Version pins — change these to upgrade
38 ARG LITESTREAM_VERSION=0.3.13
39 ARG CADDY_VERSION=2.9
40
41 # Runtime deps only (no build tools)
42 RUN apt-get update && apt-get install -y --no-install-recommends \
43 zlib1g \
44 libssl3 \
45 curl \
46 ca-certificates \
47 && rm -rf /var/lib/apt/lists/*
48
49 # Copy Fossil binary from builder
50 COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
51
52 # Install Caddy (pinned)
53 RUN curl -sSL "https://caddyserver.com/api/download?os=linux&arch=amd64&version=v${CADDY_VERSION}" \
54 -o /usr/local/bin/caddy \
55 && chmod +x /usr/local/bin/caddy
56
57 # Install Litestream (pinned)
58 RUN curl -sSL "https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-amd64.tar.gz" \
59 | tar -xz -C /usr/local/bin/
60
61 # Verify all binaries
62 RUN fossil version && caddy version && litestream version
63
64 # Create data directories
65 RUN mkdir -p /data/repos /data/trash /etc/caddy
66
67 # Copy configuration files
68 COPY Caddyfile /etc/caddy/Caddyfile
69 COPY litestream.yml /etc/litestream.yml
70
71 # Copy and install the fossilrepo package
72 COPY .. /app
73 WORKDIR /app
74 RUN pip install --no-cache-dir .
75
76 # Expose ports: Caddy HTTPS (443), Caddy HTTP (80), Fossil direct (8080)
77 EXPOSE 80 443 8080
78
79 # Litestream wraps the fossil server process, replicating all .fossil
80 # files to S3 continuously while the server runs.
81 CMD ["litestream", "replicate", "-exec", "caddy run --config /etc/caddy/Caddyfile"]
--- a/docker/docker-compose.fossil.yml
+++ b/docker/docker-compose.fossil.yml
@@ -0,0 +1,32 @@
1
+# fossilrepo local development stack
2
+#
3
+# Run: docker compose up
4
+# Creates a local Fossil server with Caddy routing and Litestream replication.
5
+
6
+services:
7
+ fossilrepo:
8
+ build:
9
+ context: ..
10
+ dockerfile: docker/Dockerfile.fossil
11
+ ports:
12
+ - "80:80"
13
+ - "443:443"
14
+ - "8080:8080"
15
+ volumes:
16
+ - fossil-data:/data/repos
17
+ environment:
18
+ # S3 replication (configure for your bucket)
19
+ - FOSSILREPO_S3_BUCKET=${FOSSILREPO_S3_BUCKET:-}
20
+ - FOSSILREPO_S3_ENDPOINT=${FOSSILREPO_S3_ENDPOINT:-}
21
+ - FOSSILREPO_S3_REGION=${FOSSILREPO_S3_REGION:-us-east-1}
22
+ - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
23
+ - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
24
+ # Server config
25
+ - FOSSILREPO_CADDY_DOMAIN=${FOSSILREPO_CADDY_DOMAIN:-localhost}
26
+ - FOSSILREPO_FOSSIL_PORT=8080
27
+ - FOSSILREPO_DATA_DIR=/data/repos
28
+ restart: unless-stopped
29
+
30
+volumes:
31
+ fossil-data:
32
+ driver: local
--- a/docker/docker-compose.fossil.yml
+++ b/docker/docker-compose.fossil.yml
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/docker-compose.fossil.yml
+++ b/docker/docker-compose.fossil.yml
@@ -0,0 +1,32 @@
1 # fossilrepo local development stack
2 #
3 # Run: docker compose up
4 # Creates a local Fossil server with Caddy routing and Litestream replication.
5
6 services:
7 fossilrepo:
8 build:
9 context: ..
10 dockerfile: docker/Dockerfile.fossil
11 ports:
12 - "80:80"
13 - "443:443"
14 - "8080:8080"
15 volumes:
16 - fossil-data:/data/repos
17 environment:
18 # S3 replication (configure for your bucket)
19 - FOSSILREPO_S3_BUCKET=${FOSSILREPO_S3_BUCKET:-}
20 - FOSSILREPO_S3_ENDPOINT=${FOSSILREPO_S3_ENDPOINT:-}
21 - FOSSILREPO_S3_REGION=${FOSSILREPO_S3_REGION:-us-east-1}
22 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
23 - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
24 # Server config
25 - FOSSILREPO_CADDY_DOMAIN=${FOSSILREPO_CADDY_DOMAIN:-localhost}
26 - FOSSILREPO_FOSSIL_PORT=8080
27 - FOSSILREPO_DATA_DIR=/data/repos
28 restart: unless-stopped
29
30 volumes:
31 fossil-data:
32 driver: local
--- a/docker/litestream.yml
+++ b/docker/litestream.yml
@@ -0,0 +1,18 @@
1
+# Litestream replication configuration
2
+#
3
+# Continuously replicates all .fossil files in /data/repos/ to S3.
4
+# Each .fossil file is a SQLite database — Litestream streams WAL
5
+# changes to S3 for continuous backup and point-in-time recovery.
6
+#
7
+# New .fossil files are picked up automatically when using the
8
+# "dbs" glob pattern below.
9
+
10
+dbs:
11
+ - path: /data/repos/*.fossil
12
+ replicas:
13
+ - type: s3
14
+ bucket: ${FOSSILREPO_S3_BUCKET}
15
+ endpoint: ${FOSSILREPO_S3_ENDPOINT}
16
+ region: ${FOSSILREPO_S3_REGION}
17
+ access-key-id: ${AWS_ACCESS_KEY_ID}
18
+ secret-access-key: ${AWS_SECRET_ACCESS_KEY}
--- a/docker/litestream.yml
+++ b/docker/litestream.yml
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/litestream.yml
+++ b/docker/litestream.yml
@@ -0,0 +1,18 @@
1 # Litestream replication configuration
2 #
3 # Continuously replicates all .fossil files in /data/repos/ to S3.
4 # Each .fossil file is a SQLite database — Litestream streams WAL
5 # changes to S3 for continuous backup and point-in-time recovery.
6 #
7 # New .fossil files are picked up automatically when using the
8 # "dbs" glob pattern below.
9
10 dbs:
11 - path: /data/repos/*.fossil
12 replicas:
13 - type: s3
14 bucket: ${FOSSILREPO_S3_BUCKET}
15 endpoint: ${FOSSILREPO_S3_ENDPOINT}
16 region: ${FOSSILREPO_S3_REGION}
17 access-key-id: ${AWS_ACCESS_KEY_ID}
18 secret-access-key: ${AWS_SECRET_ACCESS_KEY}
--- a/fossil-platform/Dockerfile
+++ b/fossil-platform/Dockerfile
@@ -0,0 +1,29 @@
1
+# Use an official Python runtime as a parent image
2
+FROM python:3.9-slim
3
+
4
+# Set environment variables
5
+ENV PYTHONDONTWRITEBYTECODE 1
6
+ENV PYTHONUNBUFFERED 1
7
+
8
+# Set work directory
9
+WORKDIR /app
10
+
11
+# Install system dependencies
12
+RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ fossil \
14
+ default-mysql-client \
15
+ postgresql-client \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+# Install Python dependencies
19
+COPY requirements.txt /app/
20
+RUN pip install --upgrade pip && pip install -r requirements.txt
21
+
22
+# Copy project
23
+COPY . /app/
24
+
25
+# Expose port
26
+EXPOSE 5000
27
+
28
+# Run the application
29
+CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
--- a/fossil-platform/Dockerfile
+++ b/fossil-platform/Dockerfile
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil-platform/Dockerfile
+++ b/fossil-platform/Dockerfile
@@ -0,0 +1,29 @@
1 # Use an official Python runtime as a parent image
2 FROM python:3.9-slim
3
4 # Set environment variables
5 ENV PYTHONDONTWRITEBYTECODE 1
6 ENV PYTHONUNBUFFERED 1
7
8 # Set work directory
9 WORKDIR /app
10
11 # Install system dependencies
12 RUN apt-get update && apt-get install -y --no-install-recommends \
13 fossil \
14 default-mysql-client \
15 postgresql-client \
16 && rm -rf /var/lib/apt/lists/*
17
18 # Install Python dependencies
19 COPY requirements.txt /app/
20 RUN pip install --upgrade pip && pip install -r requirements.txt
21
22 # Copy project
23 COPY . /app/
24
25 # Expose port
26 EXPOSE 5000
27
28 # Run the application
29 CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
--- a/fossil-platform/README.md
+++ b/fossil-platform/README.md
@@ -0,0 +1,65 @@
1
+# Fossil SCM-based GitHub/GitLab-like Platform
2
+
3
+This project aims to create a GitHub/GitLab-like platform based on Fossil SCM, providing a comprehensive solution for repository management, issue tracking, wikis, user management, and repository analytics.
4
+
5
+## Core Features
6
+
7
+- Version Control: Utilizes Fossil SCM for repository management
8
+- Backend: Flask-based API for interacting with Fossil SCM and managing platform features
9
+- Database: Supports both MySQL and PostgreSQL via feature flags
10
+- Frontend: React-based web interface for viewing repositories, commits, issues, wikis, and user management
11
+- Authentication & Permissions: OAuth (Google, GitHub) and custom JWT-based authentication
12
+- CI/CD Integration: Optional continuous integration (feature flag enabled)
13
+- Notification System: Email notifications and real-time WebSocket updates
14
+- Extensibility: Plugin system for additional features
15
+
16
+## Technical Stack
17
+
18
+- Backend: Flask (Python)
19
+- Frontend: React (JavaScript)
20
+- Database: MySQL/PostgreSQL (configurable via feature flags)
21
+- ORM: SQLAlchemy
22
+- Authentication: OAuth 2.0, JWT
23
+- Real-time Updates: WebSockets
24
+- CI/CD: (Optional, configurable)
25
+
26
+## Feature Flags
27
+
28
+The platform uses feature flags to enable/disable certain functionalities:
29
+
30
+- `DB_TYPE`: Toggle between MySQL and PostgreSQL (e.g., `DB_TYPE=mysql` or `DB_TYPE=postgres`)
31
+- `ENABLE_CICD`: Enable/disable CI/CD integration (e.g., `ENABLE_CICD=true`)
32
+- `ENABLE_NOTIFICATIONS`: Enable/disable real-time WebSocket updates and email notifications (e.g., `ENABLE_NOTIFICATIONS=true`)
33
+- `AUTH_TYPE`: Choose between OAuth-based login or JWT (e.g., `AUTH_TYPE=oauth` or `AUTH_TYPE=jwt`)
34
+
35
+## Getting Started
36
+
37
+1. Clone the repository
38
+2. Set up the backend:
39
+ - Install Python dependencies: `pip install -r requirements.txt`
40
+ - Configure environment variables for feature flags
41
+ - Run the Flask server: `python app.py`
42
+3. Set up the frontend:
43
+ - Navigate to the frontend directory: `cd frontend`
44
+ - Install npm packages: `npm install`
45
+ - Start the React app: `npm start`
46
+
47
+## Development Roadmap
48
+
49
+1. Set up Fossil SCM integration
50
+2. Implement database abstraction with SQLAlchemy
51
+3. Develop core Flask backend API
52
+4. Create basic React frontend
53
+5. Implement authentication and authorization
54
+6. Add notification system
55
+7. Develop plugin system for extensibility
56
+8. Implement CI/CD integration
57
+9. Comprehensive testing and documentation
58
+
59
+## Contributing
60
+
61
+Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
62
+
63
+## License
64
+
65
+This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
--- a/fossil-platform/README.md
+++ b/fossil-platform/README.md
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil-platform/README.md
+++ b/fossil-platform/README.md
@@ -0,0 +1,65 @@
1 # Fossil SCM-based GitHub/GitLab-like Platform
2
3 This project aims to create a GitHub/GitLab-like platform based on Fossil SCM, providing a comprehensive solution for repository management, issue tracking, wikis, user management, and repository analytics.
4
5 ## Core Features
6
7 - Version Control: Utilizes Fossil SCM for repository management
8 - Backend: Flask-based API for interacting with Fossil SCM and managing platform features
9 - Database: Supports both MySQL and PostgreSQL via feature flags
10 - Frontend: React-based web interface for viewing repositories, commits, issues, wikis, and user management
11 - Authentication & Permissions: OAuth (Google, GitHub) and custom JWT-based authentication
12 - CI/CD Integration: Optional continuous integration (feature flag enabled)
13 - Notification System: Email notifications and real-time WebSocket updates
14 - Extensibility: Plugin system for additional features
15
16 ## Technical Stack
17
18 - Backend: Flask (Python)
19 - Frontend: React (JavaScript)
20 - Database: MySQL/PostgreSQL (configurable via feature flags)
21 - ORM: SQLAlchemy
22 - Authentication: OAuth 2.0, JWT
23 - Real-time Updates: WebSockets
24 - CI/CD: (Optional, configurable)
25
26 ## Feature Flags
27
28 The platform uses feature flags to enable/disable certain functionalities:
29
30 - `DB_TYPE`: Toggle between MySQL and PostgreSQL (e.g., `DB_TYPE=mysql` or `DB_TYPE=postgres`)
31 - `ENABLE_CICD`: Enable/disable CI/CD integration (e.g., `ENABLE_CICD=true`)
32 - `ENABLE_NOTIFICATIONS`: Enable/disable real-time WebSocket updates and email notifications (e.g., `ENABLE_NOTIFICATIONS=true`)
33 - `AUTH_TYPE`: Choose between OAuth-based login or JWT (e.g., `AUTH_TYPE=oauth` or `AUTH_TYPE=jwt`)
34
35 ## Getting Started
36
37 1. Clone the repository
38 2. Set up the backend:
39 - Install Python dependencies: `pip install -r requirements.txt`
40 - Configure environment variables for feature flags
41 - Run the Flask server: `python app.py`
42 3. Set up the frontend:
43 - Navigate to the frontend directory: `cd frontend`
44 - Install npm packages: `npm install`
45 - Start the React app: `npm start`
46
47 ## Development Roadmap
48
49 1. Set up Fossil SCM integration
50 2. Implement database abstraction with SQLAlchemy
51 3. Develop core Flask backend API
52 4. Create basic React frontend
53 5. Implement authentication and authorization
54 6. Add notification system
55 7. Develop plugin system for extensibility
56 8. Implement CI/CD integration
57 9. Comprehensive testing and documentation
58
59 ## Contributing
60
61 Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
62
63 ## License
64
65 This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.

No diff available

--- a/fossil/admin.py
+++ b/fossil/admin.py
@@ -0,0 +1,25 @@
1
+from django.contrib import admin
2
+
3
+from core.admin import BaseCoreAdmin
4
+
5
+from .rum import ForumPoste(admin.TabularInline):
6
+ model = FossilSnapshot
7
+ extra = 0
8
+ readonly_fields = ("file", "file_size_bytes", "fossil_hash")
9
+
10
+
11
+@admin.register(FossilRepository)
12
+class FossilRepositoryAdmin(BaseCoreAdmin):
13
+ list_display = ("filename", "project", "file_size_bytes", "checkin_count", "last_checkin_at")
14
+ search_fields = ("filename", "project__name")
15
+ raw_id_fields = ("project",)
16
+ inlines = [FossilSnapshotInline]
17
+
18
+
19
+@admin.register(FossilSnapshot)
20
+class FossilSnapshotAdmin(BaseCoreAdmin):
21
+ list_display = ("repository", "file_size_bytes", "fossil_hash", "created_at")
22
+ raw_id_fields = ("repository",)
23
+
24
+
25
+class SyncLogInline(admin
--- a/fossil/admin.py
+++ b/fossil/admin.py
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/admin.py
+++ b/fossil/admin.py
@@ -0,0 +1,25 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .rum import ForumPoste(admin.TabularInline):
6 model = FossilSnapshot
7 extra = 0
8 readonly_fields = ("file", "file_size_bytes", "fossil_hash")
9
10
11 @admin.register(FossilRepository)
12 class FossilRepositoryAdmin(BaseCoreAdmin):
13 list_display = ("filename", "project", "file_size_bytes", "checkin_count", "last_checkin_at")
14 search_fields = ("filename", "project__name")
15 raw_id_fields = ("project",)
16 inlines = [FossilSnapshotInline]
17
18
19 @admin.register(FossilSnapshot)
20 class FossilSnapshotAdmin(BaseCoreAdmin):
21 list_display = ("repository", "file_size_bytes", "fossil_hash", "created_at")
22 raw_id_fields = ("repository",)
23
24
25 class SyncLogInline(admin
--- a/fossil/apps.py
+++ b/fossil/apps.py
@@ -0,0 +1,9 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class FossilConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "fossil"
7
+
8
+ def ready(self):
9
+ import fossil.signals # noqa: F401
--- a/fossil/apps.py
+++ b/fossil/apps.py
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
--- a/fossil/apps.py
+++ b/fossil/apps.py
@@ -0,0 +1,9 @@
1 from django.apps import AppConfig
2
3
4 class FossilConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "fossil"
7
8 def ready(self):
9 import fossil.signals # noqa: F401
--- a/fossil/cli.py
+++ b/fossil/cli.py
@@ -0,0 +1,36 @@
1
+"""Thin wrapper around the fossil binary for write operations."""
2
+
3
+import subprocess
4
+from pathlib import Path
5
+
6
+
7
+class FossilCLI:
8
+ """Wrapper around the fossil binary for write operations."""
9
+
10
+ def __init__(self, binary: str | None = None):
11
+ if binary is None:
12
+ from constance import config
13
+
14
+ binary = config.FOSSIL_BINARY_PATH
15
+ self.binary = binary
16
+
17
+ def _run(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess:
18
+ cmd = [self.binary, *args]
19
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=True)
20
+
21
+ def init(self, path: Path) -> Path:
22
+ """Create a new .fossil repository."""
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ self._run("init", str(path))
25
+ return path
26
+
27
+ def version(self) -> str:
28
+ result = self._run("version")
29
+ return result.stdout.strip()
30
+
31
+ def is_available(self) -> bool:
32
+ try:
33
+ self._run("version")
34
+ return True
35
+ except (FileNotFoundError, subprocess.CalledProcessError):
36
+ return False
--- a/fossil/cli.py
+++ b/fossil/cli.py
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/cli.py
+++ b/fossil/cli.py
@@ -0,0 +1,36 @@
1 """Thin wrapper around the fossil binary for write operations."""
2
3 import subprocess
4 from pathlib import Path
5
6
7 class FossilCLI:
8 """Wrapper around the fossil binary for write operations."""
9
10 def __init__(self, binary: str | None = None):
11 if binary is None:
12 from constance import config
13
14 binary = config.FOSSIL_BINARY_PATH
15 self.binary = binary
16
17 def _run(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess:
18 cmd = [self.binary, *args]
19 return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=True)
20
21 def init(self, path: Path) -> Path:
22 """Create a new .fossil repository."""
23 path.parent.mkdir(parents=True, exist_ok=True)
24 self._run("init", str(path))
25 return path
26
27 def version(self) -> str:
28 result = self._run("version")
29 return result.stdout.strip()
30
31 def is_available(self) -> bool:
32 try:
33 self._run("version")
34 return True
35 except (FileNotFoundError, subprocess.CalledProcessError):
36 return False
--- a/fossil/migrations/0001_initial.py
+++ b/fossil/migrations/0001_initial.py
@@ -0,0 +1,363 @@
1
+# Generated by Django 5.2.12 on 2026-04-06 02:07
2
+
3
+import django.db.models.deletion
4
+import simple_history.models
5
+from django.conf import settings
6
+from django.db import migrations, models
7
+
8
+
9
+class Migration(migrations.Migration):
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ("projects", "0001_initial"),
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name="FossilRepository",
20
+ fields=[
21
+ (
22
+ "id",
23
+ models.BigAutoField(
24
+ auto_created=True,
25
+ primary_key=True,
26
+ serialize=False,
27
+ verbose_name="ID",
28
+ ),
29
+ ),
30
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
31
+ ("created_at", models.DateTimeField(auto_now_add=True)),
32
+ ("updated_at", models.DateTimeField(auto_now=True)),
33
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
34
+ (
35
+ "filename",
36
+ models.CharField(
37
+ help_text="Filename relative to FOSSIL_DATA_DIR",
38
+ max_length=255,
39
+ unique=True,
40
+ ),
41
+ ),
42
+ ("file_size_bytes", models.BigIntegerField(default=0)),
43
+ (
44
+ "fossil_project_code",
45
+ models.CharField(blank=True, default="", max_length=40),
46
+ ),
47
+ ("last_checkin_at", models.DateTimeField(blank=True, null=True)),
48
+ ("checkin_count", models.PositiveIntegerField(default=0)),
49
+ ("s3_key", models.CharField(blank=True, default="", max_length=500)),
50
+ ("s3_last_replicated_at", models.DateTimeField(blank=True, null=True)),
51
+ (
52
+ "created_by",
53
+ models.ForeignKey(
54
+ blank=True,
55
+ null=True,
56
+ on_delete=django.db.models.deletion.SET_NULL,
57
+ related_name="+",
58
+ to=settings.AUTH_USER_MODEL,
59
+ ),
60
+ ),
61
+ (
62
+ "deleted_by",
63
+ models.ForeignKey(
64
+ blank=True,
65
+ null=True,
66
+ on_delete=django.db.models.deletion.SET_NULL,
67
+ related_name="+",
68
+ to=settings.AUTH_USER_MODEL,
69
+ ),
70
+ ),
71
+ (
72
+ "project",
73
+ models.OneToOneField(
74
+ on_delete=django.db.models.deletion.CASCADE,
75
+ related_name="fossil_repo",
76
+ to="projects.project",
77
+ ),
78
+ ),
79
+ (
80
+ "updated_by",
81
+ models.ForeignKey(
82
+ blank=True,
83
+ null=True,
84
+ on_delete=django.db.models.deletion.SET_NULL,
85
+ related_name="+",
86
+ to=settings.AUTH_USER_MODEL,
87
+ ),
88
+ ),
89
+ ],
90
+ options={
91
+ "verbose_name": "Fossil Repository",
92
+ "verbose_name_plural": "Fossil Repositories",
93
+ "ordering": ["filename"],
94
+ },
95
+ ),
96
+ migrations.CreateModel(
97
+ name="FossilSnapshot",
98
+ fields=[
99
+ (
100
+ "id",
101
+ models.BigAutoField(
102
+ auto_created=True,
103
+ primary_key=True,
104
+ serialize=False,
105
+ verbose_name="ID",
106
+ ),
107
+ ),
108
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
109
+ ("created_at", models.DateTimeField(auto_now_add=True)),
110
+ ("updated_at", models.DateTimeField(auto_now=True)),
111
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
112
+ ("file", models.FileField(upload_to="fossil_snapshots/%Y/%m/")),
113
+ ("file_size_bytes", models.BigIntegerField(default=0)),
114
+ (
115
+ "fossil_hash",
116
+ models.CharField(
117
+ blank=True,
118
+ default="",
119
+ help_text="SHA-256 of the .fossil file",
120
+ max_length=64,
121
+ ),
122
+ ),
123
+ ("note", models.CharField(blank=True, default="", max_length=200)),
124
+ (
125
+ "created_by",
126
+ models.ForeignKey(
127
+ blank=True,
128
+ null=True,
129
+ on_delete=django.db.models.deletion.SET_NULL,
130
+ related_name="+",
131
+ to=settings.AUTH_USER_MODEL,
132
+ ),
133
+ ),
134
+ (
135
+ "deleted_by",
136
+ models.ForeignKey(
137
+ blank=True,
138
+ null=True,
139
+ on_delete=django.db.models.deletion.SET_NULL,
140
+ related_name="+",
141
+ to=settings.AUTH_USER_MODEL,
142
+ ),
143
+ ),
144
+ (
145
+ "repository",
146
+ models.ForeignKey(
147
+ on_delete=django.db.models.deletion.CASCADE,
148
+ related_name="snapshots",
149
+ to="fossil.fossilrepository",
150
+ ),
151
+ ),
152
+ (
153
+ "updated_by",
154
+ models.ForeignKey(
155
+ blank=True,
156
+ null=True,
157
+ on_delete=django.db.models.deletion.SET_NULL,
158
+ related_name="+",
159
+ to=settings.AUTH_USER_MODEL,
160
+ ),
161
+ ),
162
+ ],
163
+ options={
164
+ "ordering": ["-created_at"],
165
+ "get_latest_by": "created_at",
166
+ },
167
+ ),
168
+ migrations.CreateModel(
169
+ name="HistoricalFossilRepository",
170
+ fields=[
171
+ (
172
+ "id",
173
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
174
+ ),
175
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
176
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
177
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
178
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
179
+ (
180
+ "filename",
181
+ models.CharField(
182
+ db_index=True,
183
+ help_text="Filename relative to FOSSIL_DATA_DIR",
184
+ max_length=255,
185
+ ),
186
+ ),
187
+ ("file_size_bytes", models.BigIntegerField(default=0)),
188
+ (
189
+ "fossil_project_code",
190
+ models.CharField(blank=True, default="", max_length=40),
191
+ ),
192
+ ("last_checkin_at", models.DateTimeField(blank=True, null=True)),
193
+ ("checkin_count", models.PositiveIntegerField(default=0)),
194
+ ("s3_key", models.CharField(blank=True, default="", max_length=500)),
195
+ ("s3_last_replicated_at", models.DateTimeField(blank=True, null=True)),
196
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
197
+ ("history_date", models.DateTimeField(db_index=True)),
198
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
199
+ (
200
+ "history_type",
201
+ models.CharField(
202
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
203
+ max_length=1,
204
+ ),
205
+ ),
206
+ (
207
+ "created_by",
208
+ models.ForeignKey(
209
+ blank=True,
210
+ db_constraint=False,
211
+ null=True,
212
+ on_delete=django.db.models.deletion.DO_NOTHING,
213
+ related_name="+",
214
+ to=settings.AUTH_USER_MODEL,
215
+ ),
216
+ ),
217
+ (
218
+ "deleted_by",
219
+ models.ForeignKey(
220
+ blank=True,
221
+ db_constraint=False,
222
+ null=True,
223
+ on_delete=django.db.models.deletion.DO_NOTHING,
224
+ related_name="+",
225
+ to=settings.AUTH_USER_MODEL,
226
+ ),
227
+ ),
228
+ (
229
+ "history_user",
230
+ models.ForeignKey(
231
+ null=True,
232
+ on_delete=django.db.models.deletion.SET_NULL,
233
+ related_name="+",
234
+ to=settings.AUTH_USER_MODEL,
235
+ ),
236
+ ),
237
+ (
238
+ "project",
239
+ models.ForeignKey(
240
+ blank=True,
241
+ db_constraint=False,
242
+ null=True,
243
+ on_delete=django.db.models.deletion.DO_NOTHING,
244
+ related_name="+",
245
+ to="projects.project",
246
+ ),
247
+ ),
248
+ (
249
+ "updated_by",
250
+ models.ForeignKey(
251
+ blank=True,
252
+ db_constraint=False,
253
+ null=True,
254
+ on_delete=django.db.models.deletion.DO_NOTHING,
255
+ related_name="+",
256
+ to=settings.AUTH_USER_MODEL,
257
+ ),
258
+ ),
259
+ ],
260
+ options={
261
+ "verbose_name": "historical Fossil Repository",
262
+ "verbose_name_plural": "historical Fossil Repositories",
263
+ "ordering": ("-history_date", "-history_id"),
264
+ "get_latest_by": ("history_date", "history_id"),
265
+ },
266
+ bases=(simple_history.models.HistoricalChanges, models.Model),
267
+ ),
268
+ migrations.CreateModel(
269
+ name="HistoricalFossilSnapshot",
270
+ fields=[
271
+ (
272
+ "id",
273
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
274
+ ),
275
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
276
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
277
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
278
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
279
+ ("file", models.TextField(max_length=100)),
280
+ ("file_size_bytes", models.BigIntegerField(default=0)),
281
+ (
282
+ "fossil_hash",
283
+ models.CharField(
284
+ blank=True,
285
+ default="",
286
+ help_text="SHA-256 of the .fossil file",
287
+ max_length=64,
288
+ ),
289
+ ),
290
+ ("note", models.CharField(blank=True, default="", max_length=200)),
291
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
292
+ ("history_date", models.DateTimeField(db_index=True)),
293
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
294
+ (
295
+ "history_type",
296
+ models.CharField(
297
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
298
+ max_length=1,
299
+ ),
300
+ ),
301
+ (
302
+ "created_by",
303
+ models.ForeignKey(
304
+ blank=True,
305
+ db_constraint=False,
306
+ null=True,
307
+ on_delete=django.db.models.deletion.DO_NOTHING,
308
+ related_name="+",
309
+ to=settings.AUTH_USER_MODEL,
310
+ ),
311
+ ),
312
+ (
313
+ "deleted_by",
314
+ models.ForeignKey(
315
+ blank=True,
316
+ db_constraint=False,
317
+ null=True,
318
+ on_delete=django.db.models.deletion.DO_NOTHING,
319
+ related_name="+",
320
+ to=settings.AUTH_USER_MODEL,
321
+ ),
322
+ ),
323
+ (
324
+ "history_user",
325
+ models.ForeignKey(
326
+ null=True,
327
+ on_delete=django.db.models.deletion.SET_NULL,
328
+ related_name="+",
329
+ to=settings.AUTH_USER_MODEL,
330
+ ),
331
+ ),
332
+ (
333
+ "repository",
334
+ models.ForeignKey(
335
+ blank=True,
336
+ db_constraint=False,
337
+ null=True,
338
+ on_delete=django.db.models.deletion.DO_NOTHING,
339
+ related_name="+",
340
+ to="fossil.fossilrepository",
341
+ ),
342
+ ),
343
+ (
344
+ "updated_by",
345
+ models.ForeignKey(
346
+ blank=True,
347
+ db_constraint=False,
348
+ null=True,
349
+ on_delete=django.db.models.deletion.DO_NOTHING,
350
+ related_name="+",
351
+ to=settings.AUTH_USER_MODEL,
352
+ ),
353
+ ),
354
+ ],
355
+ options={
356
+ "verbose_name": "historical fossil snapshot",
357
+ "verbose_name_plural": "historical fossil snapshots",
358
+ "ordering": ("-history_date", "-history_id"),
359
+ "get_latest_by": ("history_date", "history_id"),
360
+ },
361
+ bases=(simple_history.models.HistoricalChanges, models.Model),
362
+ ),
363
+ ]
--- a/fossil/migrations/0001_initial.py
+++ b/fossil/migrations/0001_initial.py
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0001_initial.py
+++ b/fossil/migrations/0001_initial.py
@@ -0,0 +1,363 @@
1 # Generated by Django 5.2.12 on 2026-04-06 02:07
2
3 import django.db.models.deletion
4 import simple_history.models
5 from django.conf import settings
6 from django.db import migrations, models
7
8
9 class Migration(migrations.Migration):
10 initial = True
11
12 dependencies = [
13 ("projects", "0001_initial"),
14 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15 ]
16
17 operations = [
18 migrations.CreateModel(
19 name="FossilRepository",
20 fields=[
21 (
22 "id",
23 models.BigAutoField(
24 auto_created=True,
25 primary_key=True,
26 serialize=False,
27 verbose_name="ID",
28 ),
29 ),
30 ("version", models.PositiveIntegerField(default=1, editable=False)),
31 ("created_at", models.DateTimeField(auto_now_add=True)),
32 ("updated_at", models.DateTimeField(auto_now=True)),
33 ("deleted_at", models.DateTimeField(blank=True, null=True)),
34 (
35 "filename",
36 models.CharField(
37 help_text="Filename relative to FOSSIL_DATA_DIR",
38 max_length=255,
39 unique=True,
40 ),
41 ),
42 ("file_size_bytes", models.BigIntegerField(default=0)),
43 (
44 "fossil_project_code",
45 models.CharField(blank=True, default="", max_length=40),
46 ),
47 ("last_checkin_at", models.DateTimeField(blank=True, null=True)),
48 ("checkin_count", models.PositiveIntegerField(default=0)),
49 ("s3_key", models.CharField(blank=True, default="", max_length=500)),
50 ("s3_last_replicated_at", models.DateTimeField(blank=True, null=True)),
51 (
52 "created_by",
53 models.ForeignKey(
54 blank=True,
55 null=True,
56 on_delete=django.db.models.deletion.SET_NULL,
57 related_name="+",
58 to=settings.AUTH_USER_MODEL,
59 ),
60 ),
61 (
62 "deleted_by",
63 models.ForeignKey(
64 blank=True,
65 null=True,
66 on_delete=django.db.models.deletion.SET_NULL,
67 related_name="+",
68 to=settings.AUTH_USER_MODEL,
69 ),
70 ),
71 (
72 "project",
73 models.OneToOneField(
74 on_delete=django.db.models.deletion.CASCADE,
75 related_name="fossil_repo",
76 to="projects.project",
77 ),
78 ),
79 (
80 "updated_by",
81 models.ForeignKey(
82 blank=True,
83 null=True,
84 on_delete=django.db.models.deletion.SET_NULL,
85 related_name="+",
86 to=settings.AUTH_USER_MODEL,
87 ),
88 ),
89 ],
90 options={
91 "verbose_name": "Fossil Repository",
92 "verbose_name_plural": "Fossil Repositories",
93 "ordering": ["filename"],
94 },
95 ),
96 migrations.CreateModel(
97 name="FossilSnapshot",
98 fields=[
99 (
100 "id",
101 models.BigAutoField(
102 auto_created=True,
103 primary_key=True,
104 serialize=False,
105 verbose_name="ID",
106 ),
107 ),
108 ("version", models.PositiveIntegerField(default=1, editable=False)),
109 ("created_at", models.DateTimeField(auto_now_add=True)),
110 ("updated_at", models.DateTimeField(auto_now=True)),
111 ("deleted_at", models.DateTimeField(blank=True, null=True)),
112 ("file", models.FileField(upload_to="fossil_snapshots/%Y/%m/")),
113 ("file_size_bytes", models.BigIntegerField(default=0)),
114 (
115 "fossil_hash",
116 models.CharField(
117 blank=True,
118 default="",
119 help_text="SHA-256 of the .fossil file",
120 max_length=64,
121 ),
122 ),
123 ("note", models.CharField(blank=True, default="", max_length=200)),
124 (
125 "created_by",
126 models.ForeignKey(
127 blank=True,
128 null=True,
129 on_delete=django.db.models.deletion.SET_NULL,
130 related_name="+",
131 to=settings.AUTH_USER_MODEL,
132 ),
133 ),
134 (
135 "deleted_by",
136 models.ForeignKey(
137 blank=True,
138 null=True,
139 on_delete=django.db.models.deletion.SET_NULL,
140 related_name="+",
141 to=settings.AUTH_USER_MODEL,
142 ),
143 ),
144 (
145 "repository",
146 models.ForeignKey(
147 on_delete=django.db.models.deletion.CASCADE,
148 related_name="snapshots",
149 to="fossil.fossilrepository",
150 ),
151 ),
152 (
153 "updated_by",
154 models.ForeignKey(
155 blank=True,
156 null=True,
157 on_delete=django.db.models.deletion.SET_NULL,
158 related_name="+",
159 to=settings.AUTH_USER_MODEL,
160 ),
161 ),
162 ],
163 options={
164 "ordering": ["-created_at"],
165 "get_latest_by": "created_at",
166 },
167 ),
168 migrations.CreateModel(
169 name="HistoricalFossilRepository",
170 fields=[
171 (
172 "id",
173 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
174 ),
175 ("version", models.PositiveIntegerField(default=1, editable=False)),
176 ("created_at", models.DateTimeField(blank=True, editable=False)),
177 ("updated_at", models.DateTimeField(blank=True, editable=False)),
178 ("deleted_at", models.DateTimeField(blank=True, null=True)),
179 (
180 "filename",
181 models.CharField(
182 db_index=True,
183 help_text="Filename relative to FOSSIL_DATA_DIR",
184 max_length=255,
185 ),
186 ),
187 ("file_size_bytes", models.BigIntegerField(default=0)),
188 (
189 "fossil_project_code",
190 models.CharField(blank=True, default="", max_length=40),
191 ),
192 ("last_checkin_at", models.DateTimeField(blank=True, null=True)),
193 ("checkin_count", models.PositiveIntegerField(default=0)),
194 ("s3_key", models.CharField(blank=True, default="", max_length=500)),
195 ("s3_last_replicated_at", models.DateTimeField(blank=True, null=True)),
196 ("history_id", models.AutoField(primary_key=True, serialize=False)),
197 ("history_date", models.DateTimeField(db_index=True)),
198 ("history_change_reason", models.CharField(max_length=100, null=True)),
199 (
200 "history_type",
201 models.CharField(
202 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
203 max_length=1,
204 ),
205 ),
206 (
207 "created_by",
208 models.ForeignKey(
209 blank=True,
210 db_constraint=False,
211 null=True,
212 on_delete=django.db.models.deletion.DO_NOTHING,
213 related_name="+",
214 to=settings.AUTH_USER_MODEL,
215 ),
216 ),
217 (
218 "deleted_by",
219 models.ForeignKey(
220 blank=True,
221 db_constraint=False,
222 null=True,
223 on_delete=django.db.models.deletion.DO_NOTHING,
224 related_name="+",
225 to=settings.AUTH_USER_MODEL,
226 ),
227 ),
228 (
229 "history_user",
230 models.ForeignKey(
231 null=True,
232 on_delete=django.db.models.deletion.SET_NULL,
233 related_name="+",
234 to=settings.AUTH_USER_MODEL,
235 ),
236 ),
237 (
238 "project",
239 models.ForeignKey(
240 blank=True,
241 db_constraint=False,
242 null=True,
243 on_delete=django.db.models.deletion.DO_NOTHING,
244 related_name="+",
245 to="projects.project",
246 ),
247 ),
248 (
249 "updated_by",
250 models.ForeignKey(
251 blank=True,
252 db_constraint=False,
253 null=True,
254 on_delete=django.db.models.deletion.DO_NOTHING,
255 related_name="+",
256 to=settings.AUTH_USER_MODEL,
257 ),
258 ),
259 ],
260 options={
261 "verbose_name": "historical Fossil Repository",
262 "verbose_name_plural": "historical Fossil Repositories",
263 "ordering": ("-history_date", "-history_id"),
264 "get_latest_by": ("history_date", "history_id"),
265 },
266 bases=(simple_history.models.HistoricalChanges, models.Model),
267 ),
268 migrations.CreateModel(
269 name="HistoricalFossilSnapshot",
270 fields=[
271 (
272 "id",
273 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
274 ),
275 ("version", models.PositiveIntegerField(default=1, editable=False)),
276 ("created_at", models.DateTimeField(blank=True, editable=False)),
277 ("updated_at", models.DateTimeField(blank=True, editable=False)),
278 ("deleted_at", models.DateTimeField(blank=True, null=True)),
279 ("file", models.TextField(max_length=100)),
280 ("file_size_bytes", models.BigIntegerField(default=0)),
281 (
282 "fossil_hash",
283 models.CharField(
284 blank=True,
285 default="",
286 help_text="SHA-256 of the .fossil file",
287 max_length=64,
288 ),
289 ),
290 ("note", models.CharField(blank=True, default="", max_length=200)),
291 ("history_id", models.AutoField(primary_key=True, serialize=False)),
292 ("history_date", models.DateTimeField(db_index=True)),
293 ("history_change_reason", models.CharField(max_length=100, null=True)),
294 (
295 "history_type",
296 models.CharField(
297 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
298 max_length=1,
299 ),
300 ),
301 (
302 "created_by",
303 models.ForeignKey(
304 blank=True,
305 db_constraint=False,
306 null=True,
307 on_delete=django.db.models.deletion.DO_NOTHING,
308 related_name="+",
309 to=settings.AUTH_USER_MODEL,
310 ),
311 ),
312 (
313 "deleted_by",
314 models.ForeignKey(
315 blank=True,
316 db_constraint=False,
317 null=True,
318 on_delete=django.db.models.deletion.DO_NOTHING,
319 related_name="+",
320 to=settings.AUTH_USER_MODEL,
321 ),
322 ),
323 (
324 "history_user",
325 models.ForeignKey(
326 null=True,
327 on_delete=django.db.models.deletion.SET_NULL,
328 related_name="+",
329 to=settings.AUTH_USER_MODEL,
330 ),
331 ),
332 (
333 "repository",
334 models.ForeignKey(
335 blank=True,
336 db_constraint=False,
337 null=True,
338 on_delete=django.db.models.deletion.DO_NOTHING,
339 related_name="+",
340 to="fossil.fossilrepository",
341 ),
342 ),
343 (
344 "updated_by",
345 models.ForeignKey(
346 blank=True,
347 db_constraint=False,
348 null=True,
349 on_delete=django.db.models.deletion.DO_NOTHING,
350 related_name="+",
351 to=settings.AUTH_USER_MODEL,
352 ),
353 ),
354 ],
355 options={
356 "verbose_name": "historical fossil snapshot",
357 "verbose_name_plural": "historical fossil snapshots",
358 "ordering": ("-history_date", "-history_id"),
359 "get_latest_by": ("history_date", "history_id"),
360 },
361 bases=(simple_history.models.HistoricalChanges, models.Model),
362 ),
363 ]

No diff available

--- a/fossil/models.py
+++ b/fossil/models.py
@@ -0,0 +1,3 @@
1
+syncpstream remote URL for syncme
2
+
3
+ @pro
--- a/fossil/models.py
+++ b/fossil/models.py
@@ -0,0 +1,3 @@
 
 
 
--- a/fossil/models.py
+++ b/fossil/models.py
@@ -0,0 +1,3 @@
1 syncpstream remote URL for syncme
2
3 @pro
--- a/fossil/reader.py
+++ b/fossil/reader.py
@@ -0,0 +1,513 @@
1
+"""Read-only interface to Fossil's SQLite database.
2
+
3
+Each .fossil file is a SQLite database containing all repo data:
4
+code, timeline, tickets, wiki, forum. This module reads them directly
5
+without requiring the fossil binary.
6
+"""
7
+
8
+import contextlib
9
+import sqlite3
10
+import zlib
11
+from dataclasses import dataclass, field
12
+from datetime import UTC, datetime
13
+from pathlib import Path
14
+
15
+
16
+@dataclass
17
+class TimelineEntry:
18
+ rid: int
19
+ uuid: str
20
+ event_type: str # ci=checkin, w=wiki, t=ticket, g=tag, e=technote, f=forum
21
+ timestamp: datetime
22
+ user: str
23
+ comment: str
24
+ branch: str = ""
25
+ parent_rid: int = 0 # primary parent rid for DAG drawing
26
+ is_merge: bool = False # has multiple parents
27
+ rail: int = 0 # column position for DAG graph
28
+
29
+
30
+@dataclass
31
+class FileEntry:
32
+ name: str
33
+ uuid: str
34
+ size: int
35
+ is_dir: bool = False
36
+ last_commit_message: str = ""
37
+ last_commit_user: str = ""
38
+ last_commit_time: datetime | None = None
39
+
40
+
41
+@dataclass
42
+class TicketEntry:
43
+ uuid: str
44
+ title: str
45
+ status: str
46
+ type: str
47
+ created: datetime
48
+ owner: str
49
+ subsystem: str = ""
50
+ priority: str = ""
51
+
52
+
53
+@dataclass
54
+class WikiPage:
55
+ name: str
56
+ content: str
57
+ last_modified: datetime
58
+ user: str
59
+
60
+
61
+@dataclass
62
+class ForumPost:
63
+ uuid: str
64
+ title: str
65
+ body: str
66
+ timestamp: datetime
67
+ user: str
68
+ in_reply_to: str = ""
69
+
70
+
71
+@dataclass
72
+class RepoMetadata:
73
+ project_name: str = ""
74
+ project_code: str = ""
75
+ checkin_count: int = 0
76
+ file_count: int = 0
77
+ wiki_page_count: int = 0
78
+ ticket_count: int = 0
79
+ branches: list[str] = field(default_factory=list)
80
+
81
+
82
+def _julian_to_datetime(julian: float) -> datetime:
83
+ """Convert Julian day number to Python datetime (UTC)."""
84
+
85
+ # Julian day epoch is Jan 1, 4713 BC (proleptic Julian calendar)
86
+ # Unix epoch in Julian days = 2440587.5
87
+ unix_ts = (julian - 2440587.5) * 86400.0
88
+ return datetime.fromtimestamp(unix_ts, tz=UTC)
89
+
90
+
91
+def _decompress_blob(data: bytes) -> bytes:
92
+ """Decompress a Fossil blob.
93
+
94
+ Fossil stores blobs with a 4-byte big-endian size prefix followed by
95
+ zlib-compressed content. The size prefix is the uncompressed size.
96
+ """
97
+ if not data:
98
+ return b""
99
+ # Fossil prepends uncompressed size as 4-byte big-endian int
100
+ if len(data) > 4:
101
+ payload = data[4:]
102
+ try:
103
+ return zlib.decompress(payload)
104
+ except zlib.error:
105
+ pass
106
+ # Fallback: try without size prefix
107
+ try:
108
+ return zlib.decompress(data)
109
+ except zlib.error:
110
+ pass
111
+ try:
112
+ return zlib.decompress(data, -zlib.MAX_WBITS)
113
+ except zlib.error:
114
+ return data # Already uncompressed or unknown format
115
+
116
+
117
+def _extract_wiki_content(artifact_text: str) -> str:
118
+ """Extract wiki body from a Fossil wiki artifact.
119
+
120
+ Format: header cards (D/L/P/U lines), then W <size>\\n<content>\\nZ <hash>
121
+ """
122
+ import re
123
+
124
+ match = re.search(r"^W \d+\n(.*?)(?:\nZ [0-9a-f]+)?$", artifact_text, re.DOTALL | re.MULTILINE)
125
+ if match:
126
+ return match.group(1).strip()
127
+ return ""
128
+
129
+
130
+class FossilReader:
131
+ """Read-only interface to a .fossil SQLite database."""
132
+
133
+ def __init__(self, path: Path):
134
+ self.path = path
135
+ self._conn: sqlite3.Connection | None = None
136
+
137
+ def __enter__(self):
138
+ self._conn = self._connect()
139
+ return self
140
+
141
+ def __exit__(self, *args):
142
+ if self._conn:
143
+ self._conn.close()
144
+ self._conn = None
145
+
146
+ def _connect(self) -> sqlite3.Connection:
147
+ uri = f"file:{self.path}?mode=ro"
148
+ conn = sqlite3.connect(uri, uri=True)
149
+ conn.row_factory = sqlite3.Row
150
+ return conn
151
+
152
+ @property
153
+ def conn(self) -> sqlite3.Connection:
154
+ if self._conn is None:
155
+ self._conn = self._connect()
156
+ return self._conn
157
+
158
+ def close(self):
159
+ if self._conn:
160
+ self._conn.close()
161
+ self._conn = None
162
+
163
+ # --- Metadata ---
164
+
165
+ def get_metadata(self) -> RepoMetadata:
166
+ meta = RepoMetadata()
167
+ meta.project_name = self.get_project_name()
168
+ meta.project_code = self.get_project_code()
169
+ meta.checkin_count = self.get_checkin_count()
170
+ with contextlib.suppress(sqlite3.OperationalError):
171
+ meta.ticket_count = self.conn.execute("SELECT count(*) FROM ticket").fetchone()[0]
172
+ with contextlib.suppress(sqlite3.OperationalError):
173
+ meta.wiki_page_count = self.conn.execute(
174
+ "SELECT count(DISTINCT substr(tagname,6)) FROM tag WHERE tagname LIKE 'wiki-%'"
175
+ ).fetchone()[0]
176
+ return meta
177
+
178
+ def get_project_name(self) -> str:
179
+ try:
180
+ row = self.conn.execute("SELECT value FROM config WHERE name='project-name'").fetchone()
181
+ return row[0] if row else ""
182
+ except sqlite3.OperationalError:
183
+ return ""
184
+
185
+ def get_project_code(self) -> str:
186
+ try:
187
+ row = self.conn.execute("SELECT value FROM config WHERE name='project-code'").fetchone()
188
+ return row[0] if row else ""
189
+ except sqlite3.OperationalError:
190
+ return ""
191
+
192
+ def get_checkin_count(self) -> int:
193
+ try:
194
+ row = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone()
195
+ return row[0] if row else 0
196
+ except sqlite3.OperationalError:
197
+ return 0
198
+
199
+ # --- Timeline ---
200
+
201
+ def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]:
202
+ sql = """
203
+ SELECT blob.rid, blob.uuid, event.type, event.mtime, event.user, event.comment
204
+ FROM event
205
+ JOIN blob ON event.objid = blob.rid
206
+ """
207
+ params: list = []
208
+ if event_type:
209
+ sql += " WHERE event.type = ?"
210
+ params.append(event_type)
211
+ sql += " ORDER BY event.mtime DESC LIMIT ? OFFSET ?"
212
+ params.extend([limit, offset])
213
+
214
+ entries = []
215
+ try:
216
+ for row in self.conn.execute(sql, params):
217
+ branch = ""
218
+ parent_rid = 0
219
+ is_merge = False
220
+
221
+ try:
222
+ br = self.conn.execute(
223
+ "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid "
224
+ "WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
225
+ (row["rid"],),
226
+ ).fetchone()
227
+ if br:
228
+ branch = br[0].replace("sym-", "", 1)
229
+ except sqlite3.OperationalError:
230
+ pass
231
+
232
+ # Get parent info from plink for DAG
233
+ if row["type"] == "ci":
234
+ try:
235
+ parents = self.conn.execute(
236
+ "SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)
237
+ ).fetchall()
238
+ for p in parents:
239
+ if p["isprim"]:
240
+ parent_rid = p["pid"]
241
+ is_merge = len(parents) > 1
242
+ except sqlite3.OperationalError:
243
+ pass
244
+
245
+ entries.append(
246
+ TimelineEntry(
247
+ rid=row["rid"],
248
+ uuid=row["uuid"],
249
+ event_type=row["type"],
250
+ timestamp=_julian_to_datetime(row["mtime"]),
251
+ user=row["user"] or "",
252
+ comment=row["comment"] or "",
253
+ branch=branch,
254
+ parent_rid=parent_rid,
255
+ is_merge=is_merge,
256
+ )
257
+ )
258
+ except sqlite3.OperationalError:
259
+ pass
260
+
261
+ # Assign rail positions based on branches
262
+ branch_rails: dict[str, int] = {}
263
+ next_rail = 0
264
+ for entry in entries:
265
+ if entry.event_type != "ci":
266
+ entry.rail = -1 # non-checkin events don't get a rail
267
+ continue
268
+ b = entry.branch or "trunk"
269
+ if b not in branch_rails:
270
+ branch_rails[b] = next_rail
271
+ next_rail += 1
272
+ entry.rail = branch_rails[b]
273
+
274
+ return entries
275
+
276
+ # --- Code / Files ---
277
+
278
+ def get_latest_checkin_uuid(self) -> str | None:
279
+ try:
280
+ row = self.conn.execute(
281
+ "SELECT blob.uuid FROM event JOIN blob ON event.objid=blob.rid WHERE event.type='ci' ORDER BY event.mtime DESC LIMIT 1"
282
+ ).fetchone()
283
+ return row[0] if row else None
284
+ except sqlite3.OperationalError:
285
+ return None
286
+
287
+ def get_files_at_checkin(self, checkin_uuid: str | None = None) -> list[FileEntry]:
288
+ """Get the cumulative file list at a given checkin, with last commit info per file."""
289
+ if checkin_uuid is None:
290
+ checkin_uuid = self.get_latest_checkin_uuid()
291
+ if not checkin_uuid:
292
+ return []
293
+
294
+ try:
295
+ # Build cumulative file state: for each filename, find the latest mlink entry
296
+ # where fid > 0 (fid=0 means file was deleted)
297
+ rows = self.conn.execute(
298
+ """
299
+ SELECT fn.name, b.uuid, b.size,
300
+ e.comment, e.user, e.mtime
301
+ FROM (
302
+ SELECT ml.fnid, ml.fid,
303
+ MAX(e2.mtime) as max_mtime
304
+ FROM mlink ml
305
+ JOIN event e2 ON ml.mid = e2.objid
306
+ WHERE e2.type = 'ci'
307
+ GROUP BY ml.fnid
308
+ ) latest
309
+ JOIN mlink ml2 ON ml2.fnid = latest.fnid
310
+ JOIN event e ON ml2.mid = e.objid AND e.mtime = latest.max_mtime AND e.type = 'ci'
311
+ JOIN filename fn ON latest.fnid = fn.fnid
312
+ LEFT JOIN blob b ON ml2.fid = b.rid
313
+ WHERE ml2.fid > 0
314
+ ORDER BY fn.name
315
+ """,
316
+ ).fetchall()
317
+
318
+ return [
319
+ FileEntry(
320
+ name=r["name"],
321
+ uuid=r["uuid"] or "",
322
+ size=r["size"] or 0,
323
+ last_commit_message=r["comment"] or "",
324
+ last_commit_user=r["user"] or "",
325
+ last_commit_time=_julian_to_datetime(r["mtime"]) if r["mtime"] else None,
326
+ )
327
+ for r in rows
328
+ ]
329
+ except sqlite3.OperationalError:
330
+ return []
331
+
332
+ def get_file_content(self, blob_uuid: str) -> bytes:
333
+ try:
334
+ row = self.conn.execute("SELECT content FROM blob WHERE uuid=?", (blob_uuid,)).fetchone()
335
+ if not row or not row[0]:
336
+ return b""
337
+ return _decompress_blob(row[0])
338
+ except sqlite3.OperationalError:
339
+ return b""
340
+
341
+ # --- Tickets ---
342
+
343
+ def get_tickets(self, status: str | None = None, limit: int = 50) -> list[TicketEntry]:
344
+ sql = "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority FROM ticket"
345
+ params: list = []
346
+ if status:
347
+ sql += " WHERE status = ?"
348
+ params.append(status)
349
+ sql += " ORDER BY tkt_ctime DESC LIMIT ?"
350
+ params.append(limit)
351
+
352
+ entries = []
353
+ try:
354
+ for row in self.conn.execute(sql, params):
355
+ entries.append(
356
+ TicketEntry(
357
+ uuid=row["tkt_uuid"] or "",
358
+ title=row["title"] or "",
359
+ status=row["status"] or "",
360
+ type=row["type"] or "",
361
+ created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
362
+ owner="",
363
+ subsystem=row["subsystem"] or "",
364
+ priority=row["priority"] or "",
365
+ )
366
+ )
367
+ except sqlite3.OperationalError:
368
+ pass
369
+ return entries
370
+
371
+ def get_ticket_detail(self, uuid: str) -> TicketEntry | None:
372
+ try:
373
+ row = self.conn.execute(
374
+ "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority "
375
+ "FROM ticket WHERE tkt_uuid LIKE ?",
376
+ (uuid + "%",),
377
+ ).fetchone()
378
+ if not row:
379
+ return None
380
+ return TicketEntry(
381
+ uuid=row["tkt_uuid"],
382
+ title=row["title"] or "",
383
+ status=row["status"] or "",
384
+ type=row["type"] or "",
385
+ created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
386
+ owner="",
387
+ subsystem=row["subsystem"] or "",
388
+ priority=row["priority"] or "",
389
+ )
390
+ except sqlite3.OperationalError:
391
+ return None
392
+
393
+ # --- Wiki ---
394
+
395
+ def get_wiki_pages(self) -> list[WikiPage]:
396
+ pages = []
397
+ try:
398
+ rows = self.conn.execute(
399
+ """
400
+ SELECT substr(tag.tagname, 6) as name, event.mtime, event.user
401
+ FROM tag
402
+ JOIN tagxref ON tag.tagid = tagxref.tagid
403
+ JOIN event ON tagxref.rid = event.objid
404
+ WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w'
405
+ GROUP BY tag.tagname
406
+ HAVING event.mtime = MAX(event.mtime)
407
+ ORDER BY name
408
+ """
409
+ ).fetchall()
410
+ for row in rows:
411
+ pages.append(
412
+ WikiPage(
413
+ name=row["name"],
414
+ content="",
415
+ last_modified=_julian_to_datetime(row["mtime"]),
416
+ user=row["user"] or "",
417
+ )
418
+ )
419
+ except sqlite3.OperationalError:
420
+ pass
421
+ return pages
422
+
423
+ def get_wiki_page(self, name: str) -> WikiPage | None:
424
+ try:
425
+ row = self.conn.execute(
426
+ """
427
+ SELECT tagxref.rid, event.mtime, event.user
428
+ FROM tag
429
+ JOIN tagxref ON tag.tagid = tagxref.tagid
430
+ JOIN event ON tagxref.rid = event.objid
431
+ WHERE tag.tagname = ? AND event.type = 'w'
432
+ ORDER BY event.mtime DESC
433
+ LIMIT 1
434
+ """,
435
+ (f"wiki-{name}",),
436
+ ).fetchone()
437
+ if not row:
438
+ return None
439
+
440
+ # Read the wiki content from the blob
441
+ blob_row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (row["rid"],)).fetchone()
442
+ content = ""
443
+ if blob_row and blob_row[0]:
444
+ raw = _decompress_blob(blob_row[0])
445
+ text = raw.decode("utf-8", errors="replace")
446
+ # Fossil wiki artifact format: header cards (D/L/P/U) then W <size>\n<content>\nZ <hash>
447
+ content = _extract_wiki_content(text)
448
+
449
+ return WikiPage(
450
+ name=name,
451
+ content=content,
452
+ last_modified=_julian_to_datetime(row["mtime"]),
453
+ user=row["user"] or "",
454
+ )
455
+ except sqlite3.OperationalError:
456
+ return None
457
+
458
+ # --- Forum ---
459
+
460
+ def get_forum_posts(self, limit: int = 50) -> list[ForumPost]:
461
+ posts = []
462
+ try:
463
+ rows = self.conn.execute(
464
+ """
465
+ SELECT blob.uuid, event.mtime, event.user, event.comment
466
+ FROM event
467
+ JOIN blob ON event.objid = blob.rid
468
+ WHERE event.type = 'f'
469
+ ORDER BY event.mtime DESC
470
+ LIMIT ?
471
+ """,
472
+ (limit,),
473
+ ).fetchall()
474
+ for row in rows:
475
+ posts.append(
476
+ ForumPost(
477
+ uuid=row["uuid"],
478
+ title=row["comment"] or "",
479
+ body="",
480
+ timestamp=_julian_to_datetime(row["mtime"]),
481
+ user=row["user"] or "",
482
+ )
483
+ )
484
+ except sqlite3.OperationalError:
485
+ pass
486
+ return posts
487
+
488
+ def get_forum_thread(self, root_uuid: str) -> list[ForumPost]:
489
+ # Forum threads in Fossil are linked via the forumpost table
490
+ posts = []
491
+ try:
492
+ rows = self.conn.execute(
493
+ """
494
+ SELECT blob.uuid, event.mtime, event.user, event.comment
495
+ FROM event
496
+ JOIN blob ON event.objid = blob.rid
497
+ WHERE event.type = 'f'
498
+ ORDER BY event.mtime ASC
499
+ """
500
+ ).fetchall()
501
+ for row in rows:
502
+ posts.append(
503
+ ForumPost(
504
+ uuid=row["uuid"],
505
+ title=row["comment"] or "",
506
+ body="",
507
+ timestamp=_julian_to_datetime(row["mtime"]),
508
+ user=row["user"] or "",
509
+ )
510
+ )
511
+ except sqlite3.OperationalError:
512
+ pass
513
+ return posts
--- a/fossil/reader.py
+++ b/fossil/reader.py
@@ -0,0 +1,513 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/reader.py
+++ b/fossil/reader.py
@@ -0,0 +1,513 @@
1 """Read-only interface to Fossil's SQLite database.
2
3 Each .fossil file is a SQLite database containing all repo data:
4 code, timeline, tickets, wiki, forum. This module reads them directly
5 without requiring the fossil binary.
6 """
7
8 import contextlib
9 import sqlite3
10 import zlib
11 from dataclasses import dataclass, field
12 from datetime import UTC, datetime
13 from pathlib import Path
14
15
16 @dataclass
17 class TimelineEntry:
18 rid: int
19 uuid: str
20 event_type: str # ci=checkin, w=wiki, t=ticket, g=tag, e=technote, f=forum
21 timestamp: datetime
22 user: str
23 comment: str
24 branch: str = ""
25 parent_rid: int = 0 # primary parent rid for DAG drawing
26 is_merge: bool = False # has multiple parents
27 rail: int = 0 # column position for DAG graph
28
29
30 @dataclass
31 class FileEntry:
32 name: str
33 uuid: str
34 size: int
35 is_dir: bool = False
36 last_commit_message: str = ""
37 last_commit_user: str = ""
38 last_commit_time: datetime | None = None
39
40
41 @dataclass
42 class TicketEntry:
43 uuid: str
44 title: str
45 status: str
46 type: str
47 created: datetime
48 owner: str
49 subsystem: str = ""
50 priority: str = ""
51
52
53 @dataclass
54 class WikiPage:
55 name: str
56 content: str
57 last_modified: datetime
58 user: str
59
60
61 @dataclass
62 class ForumPost:
63 uuid: str
64 title: str
65 body: str
66 timestamp: datetime
67 user: str
68 in_reply_to: str = ""
69
70
71 @dataclass
72 class RepoMetadata:
73 project_name: str = ""
74 project_code: str = ""
75 checkin_count: int = 0
76 file_count: int = 0
77 wiki_page_count: int = 0
78 ticket_count: int = 0
79 branches: list[str] = field(default_factory=list)
80
81
82 def _julian_to_datetime(julian: float) -> datetime:
83 """Convert Julian day number to Python datetime (UTC)."""
84
85 # Julian day epoch is Jan 1, 4713 BC (proleptic Julian calendar)
86 # Unix epoch in Julian days = 2440587.5
87 unix_ts = (julian - 2440587.5) * 86400.0
88 return datetime.fromtimestamp(unix_ts, tz=UTC)
89
90
91 def _decompress_blob(data: bytes) -> bytes:
92 """Decompress a Fossil blob.
93
94 Fossil stores blobs with a 4-byte big-endian size prefix followed by
95 zlib-compressed content. The size prefix is the uncompressed size.
96 """
97 if not data:
98 return b""
99 # Fossil prepends uncompressed size as 4-byte big-endian int
100 if len(data) > 4:
101 payload = data[4:]
102 try:
103 return zlib.decompress(payload)
104 except zlib.error:
105 pass
106 # Fallback: try without size prefix
107 try:
108 return zlib.decompress(data)
109 except zlib.error:
110 pass
111 try:
112 return zlib.decompress(data, -zlib.MAX_WBITS)
113 except zlib.error:
114 return data # Already uncompressed or unknown format
115
116
117 def _extract_wiki_content(artifact_text: str) -> str:
118 """Extract wiki body from a Fossil wiki artifact.
119
120 Format: header cards (D/L/P/U lines), then W <size>\\n<content>\\nZ <hash>
121 """
122 import re
123
124 match = re.search(r"^W \d+\n(.*?)(?:\nZ [0-9a-f]+)?$", artifact_text, re.DOTALL | re.MULTILINE)
125 if match:
126 return match.group(1).strip()
127 return ""
128
129
130 class FossilReader:
131 """Read-only interface to a .fossil SQLite database."""
132
133 def __init__(self, path: Path):
134 self.path = path
135 self._conn: sqlite3.Connection | None = None
136
137 def __enter__(self):
138 self._conn = self._connect()
139 return self
140
141 def __exit__(self, *args):
142 if self._conn:
143 self._conn.close()
144 self._conn = None
145
146 def _connect(self) -> sqlite3.Connection:
147 uri = f"file:{self.path}?mode=ro"
148 conn = sqlite3.connect(uri, uri=True)
149 conn.row_factory = sqlite3.Row
150 return conn
151
152 @property
153 def conn(self) -> sqlite3.Connection:
154 if self._conn is None:
155 self._conn = self._connect()
156 return self._conn
157
158 def close(self):
159 if self._conn:
160 self._conn.close()
161 self._conn = None
162
163 # --- Metadata ---
164
165 def get_metadata(self) -> RepoMetadata:
166 meta = RepoMetadata()
167 meta.project_name = self.get_project_name()
168 meta.project_code = self.get_project_code()
169 meta.checkin_count = self.get_checkin_count()
170 with contextlib.suppress(sqlite3.OperationalError):
171 meta.ticket_count = self.conn.execute("SELECT count(*) FROM ticket").fetchone()[0]
172 with contextlib.suppress(sqlite3.OperationalError):
173 meta.wiki_page_count = self.conn.execute(
174 "SELECT count(DISTINCT substr(tagname,6)) FROM tag WHERE tagname LIKE 'wiki-%'"
175 ).fetchone()[0]
176 return meta
177
178 def get_project_name(self) -> str:
179 try:
180 row = self.conn.execute("SELECT value FROM config WHERE name='project-name'").fetchone()
181 return row[0] if row else ""
182 except sqlite3.OperationalError:
183 return ""
184
185 def get_project_code(self) -> str:
186 try:
187 row = self.conn.execute("SELECT value FROM config WHERE name='project-code'").fetchone()
188 return row[0] if row else ""
189 except sqlite3.OperationalError:
190 return ""
191
192 def get_checkin_count(self) -> int:
193 try:
194 row = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone()
195 return row[0] if row else 0
196 except sqlite3.OperationalError:
197 return 0
198
199 # --- Timeline ---
200
201 def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]:
202 sql = """
203 SELECT blob.rid, blob.uuid, event.type, event.mtime, event.user, event.comment
204 FROM event
205 JOIN blob ON event.objid = blob.rid
206 """
207 params: list = []
208 if event_type:
209 sql += " WHERE event.type = ?"
210 params.append(event_type)
211 sql += " ORDER BY event.mtime DESC LIMIT ? OFFSET ?"
212 params.extend([limit, offset])
213
214 entries = []
215 try:
216 for row in self.conn.execute(sql, params):
217 branch = ""
218 parent_rid = 0
219 is_merge = False
220
221 try:
222 br = self.conn.execute(
223 "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid "
224 "WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
225 (row["rid"],),
226 ).fetchone()
227 if br:
228 branch = br[0].replace("sym-", "", 1)
229 except sqlite3.OperationalError:
230 pass
231
232 # Get parent info from plink for DAG
233 if row["type"] == "ci":
234 try:
235 parents = self.conn.execute(
236 "SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)
237 ).fetchall()
238 for p in parents:
239 if p["isprim"]:
240 parent_rid = p["pid"]
241 is_merge = len(parents) > 1
242 except sqlite3.OperationalError:
243 pass
244
245 entries.append(
246 TimelineEntry(
247 rid=row["rid"],
248 uuid=row["uuid"],
249 event_type=row["type"],
250 timestamp=_julian_to_datetime(row["mtime"]),
251 user=row["user"] or "",
252 comment=row["comment"] or "",
253 branch=branch,
254 parent_rid=parent_rid,
255 is_merge=is_merge,
256 )
257 )
258 except sqlite3.OperationalError:
259 pass
260
261 # Assign rail positions based on branches
262 branch_rails: dict[str, int] = {}
263 next_rail = 0
264 for entry in entries:
265 if entry.event_type != "ci":
266 entry.rail = -1 # non-checkin events don't get a rail
267 continue
268 b = entry.branch or "trunk"
269 if b not in branch_rails:
270 branch_rails[b] = next_rail
271 next_rail += 1
272 entry.rail = branch_rails[b]
273
274 return entries
275
276 # --- Code / Files ---
277
278 def get_latest_checkin_uuid(self) -> str | None:
279 try:
280 row = self.conn.execute(
281 "SELECT blob.uuid FROM event JOIN blob ON event.objid=blob.rid WHERE event.type='ci' ORDER BY event.mtime DESC LIMIT 1"
282 ).fetchone()
283 return row[0] if row else None
284 except sqlite3.OperationalError:
285 return None
286
287 def get_files_at_checkin(self, checkin_uuid: str | None = None) -> list[FileEntry]:
288 """Get the cumulative file list at a given checkin, with last commit info per file."""
289 if checkin_uuid is None:
290 checkin_uuid = self.get_latest_checkin_uuid()
291 if not checkin_uuid:
292 return []
293
294 try:
295 # Build cumulative file state: for each filename, find the latest mlink entry
296 # where fid > 0 (fid=0 means file was deleted)
297 rows = self.conn.execute(
298 """
299 SELECT fn.name, b.uuid, b.size,
300 e.comment, e.user, e.mtime
301 FROM (
302 SELECT ml.fnid, ml.fid,
303 MAX(e2.mtime) as max_mtime
304 FROM mlink ml
305 JOIN event e2 ON ml.mid = e2.objid
306 WHERE e2.type = 'ci'
307 GROUP BY ml.fnid
308 ) latest
309 JOIN mlink ml2 ON ml2.fnid = latest.fnid
310 JOIN event e ON ml2.mid = e.objid AND e.mtime = latest.max_mtime AND e.type = 'ci'
311 JOIN filename fn ON latest.fnid = fn.fnid
312 LEFT JOIN blob b ON ml2.fid = b.rid
313 WHERE ml2.fid > 0
314 ORDER BY fn.name
315 """,
316 ).fetchall()
317
318 return [
319 FileEntry(
320 name=r["name"],
321 uuid=r["uuid"] or "",
322 size=r["size"] or 0,
323 last_commit_message=r["comment"] or "",
324 last_commit_user=r["user"] or "",
325 last_commit_time=_julian_to_datetime(r["mtime"]) if r["mtime"] else None,
326 )
327 for r in rows
328 ]
329 except sqlite3.OperationalError:
330 return []
331
332 def get_file_content(self, blob_uuid: str) -> bytes:
333 try:
334 row = self.conn.execute("SELECT content FROM blob WHERE uuid=?", (blob_uuid,)).fetchone()
335 if not row or not row[0]:
336 return b""
337 return _decompress_blob(row[0])
338 except sqlite3.OperationalError:
339 return b""
340
341 # --- Tickets ---
342
343 def get_tickets(self, status: str | None = None, limit: int = 50) -> list[TicketEntry]:
344 sql = "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority FROM ticket"
345 params: list = []
346 if status:
347 sql += " WHERE status = ?"
348 params.append(status)
349 sql += " ORDER BY tkt_ctime DESC LIMIT ?"
350 params.append(limit)
351
352 entries = []
353 try:
354 for row in self.conn.execute(sql, params):
355 entries.append(
356 TicketEntry(
357 uuid=row["tkt_uuid"] or "",
358 title=row["title"] or "",
359 status=row["status"] or "",
360 type=row["type"] or "",
361 created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
362 owner="",
363 subsystem=row["subsystem"] or "",
364 priority=row["priority"] or "",
365 )
366 )
367 except sqlite3.OperationalError:
368 pass
369 return entries
370
371 def get_ticket_detail(self, uuid: str) -> TicketEntry | None:
372 try:
373 row = self.conn.execute(
374 "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority "
375 "FROM ticket WHERE tkt_uuid LIKE ?",
376 (uuid + "%",),
377 ).fetchone()
378 if not row:
379 return None
380 return TicketEntry(
381 uuid=row["tkt_uuid"],
382 title=row["title"] or "",
383 status=row["status"] or "",
384 type=row["type"] or "",
385 created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
386 owner="",
387 subsystem=row["subsystem"] or "",
388 priority=row["priority"] or "",
389 )
390 except sqlite3.OperationalError:
391 return None
392
393 # --- Wiki ---
394
395 def get_wiki_pages(self) -> list[WikiPage]:
396 pages = []
397 try:
398 rows = self.conn.execute(
399 """
400 SELECT substr(tag.tagname, 6) as name, event.mtime, event.user
401 FROM tag
402 JOIN tagxref ON tag.tagid = tagxref.tagid
403 JOIN event ON tagxref.rid = event.objid
404 WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w'
405 GROUP BY tag.tagname
406 HAVING event.mtime = MAX(event.mtime)
407 ORDER BY name
408 """
409 ).fetchall()
410 for row in rows:
411 pages.append(
412 WikiPage(
413 name=row["name"],
414 content="",
415 last_modified=_julian_to_datetime(row["mtime"]),
416 user=row["user"] or "",
417 )
418 )
419 except sqlite3.OperationalError:
420 pass
421 return pages
422
423 def get_wiki_page(self, name: str) -> WikiPage | None:
424 try:
425 row = self.conn.execute(
426 """
427 SELECT tagxref.rid, event.mtime, event.user
428 FROM tag
429 JOIN tagxref ON tag.tagid = tagxref.tagid
430 JOIN event ON tagxref.rid = event.objid
431 WHERE tag.tagname = ? AND event.type = 'w'
432 ORDER BY event.mtime DESC
433 LIMIT 1
434 """,
435 (f"wiki-{name}",),
436 ).fetchone()
437 if not row:
438 return None
439
440 # Read the wiki content from the blob
441 blob_row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (row["rid"],)).fetchone()
442 content = ""
443 if blob_row and blob_row[0]:
444 raw = _decompress_blob(blob_row[0])
445 text = raw.decode("utf-8", errors="replace")
446 # Fossil wiki artifact format: header cards (D/L/P/U) then W <size>\n<content>\nZ <hash>
447 content = _extract_wiki_content(text)
448
449 return WikiPage(
450 name=name,
451 content=content,
452 last_modified=_julian_to_datetime(row["mtime"]),
453 user=row["user"] or "",
454 )
455 except sqlite3.OperationalError:
456 return None
457
458 # --- Forum ---
459
460 def get_forum_posts(self, limit: int = 50) -> list[ForumPost]:
461 posts = []
462 try:
463 rows = self.conn.execute(
464 """
465 SELECT blob.uuid, event.mtime, event.user, event.comment
466 FROM event
467 JOIN blob ON event.objid = blob.rid
468 WHERE event.type = 'f'
469 ORDER BY event.mtime DESC
470 LIMIT ?
471 """,
472 (limit,),
473 ).fetchall()
474 for row in rows:
475 posts.append(
476 ForumPost(
477 uuid=row["uuid"],
478 title=row["comment"] or "",
479 body="",
480 timestamp=_julian_to_datetime(row["mtime"]),
481 user=row["user"] or "",
482 )
483 )
484 except sqlite3.OperationalError:
485 pass
486 return posts
487
488 def get_forum_thread(self, root_uuid: str) -> list[ForumPost]:
489 # Forum threads in Fossil are linked via the forumpost table
490 posts = []
491 try:
492 rows = self.conn.execute(
493 """
494 SELECT blob.uuid, event.mtime, event.user, event.comment
495 FROM event
496 JOIN blob ON event.objid = blob.rid
497 WHERE event.type = 'f'
498 ORDER BY event.mtime ASC
499 """
500 ).fetchall()
501 for row in rows:
502 posts.append(
503 ForumPost(
504 uuid=row["uuid"],
505 title=row["comment"] or "",
506 body="",
507 timestamp=_julian_to_datetime(row["mtime"]),
508 user=row["user"] or "",
509 )
510 )
511 except sqlite3.OperationalError:
512 pass
513 return posts
--- a/fossil/signals.py
+++ b/fossil/signals.py
@@ -0,0 +1,42 @@
1
+"""Auto-create FossilRepository when a Project is created."""
2
+
3
+import logging
4
+
5
+from django.db.models.signals import post_save
6
+from django.dispatch import receiver
7
+
8
+from projects.models import Project
9
+
10
+logger = logging.getLogger(__name__)
11
+
12
+
13
+@receiver(post_save, sender=Project)
14
+def create_fossil_repo(sender, instance, created, **kwargs):
15
+ """When a new Project is created, create a FossilRepository record and init the .fossil file."""
16
+ if not created:
17
+ return
18
+
19
+ from fossil.models import FossilRepository
20
+
21
+ if FossilRepository.objects.filter(project=instance).exists():
22
+ return
23
+
24
+ filename = f"{instance.slug}.fossil"
25
+ repo = FossilRepository.objects.create(
26
+ project=instance,
27
+ filename=filename,
28
+ created_by=instance.created_by,
29
+ )
30
+
31
+ # Try to init the .fossil file on disk
32
+ try:
33
+dated_at:
34
+ try:
35
+cli = FossilCLI()
36
+ = FossilCLI()
37
+ if cli.icli.init(rrepo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0
38
+ isk else 0
39
+ repo.save(update_fields=["file_size_bytes", "updated_atelse:
40
+ logger.except Exception:
41
+ ept Exception:
42
+ logger.
--- a/fossil/signals.py
+++ b/fossil/signals.py
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/signals.py
+++ b/fossil/signals.py
@@ -0,0 +1,42 @@
1 """Auto-create FossilRepository when a Project is created."""
2
3 import logging
4
5 from django.db.models.signals import post_save
6 from django.dispatch import receiver
7
8 from projects.models import Project
9
10 logger = logging.getLogger(__name__)
11
12
13 @receiver(post_save, sender=Project)
14 def create_fossil_repo(sender, instance, created, **kwargs):
15 """When a new Project is created, create a FossilRepository record and init the .fossil file."""
16 if not created:
17 return
18
19 from fossil.models import FossilRepository
20
21 if FossilRepository.objects.filter(project=instance).exists():
22 return
23
24 filename = f"{instance.slug}.fossil"
25 repo = FossilRepository.objects.create(
26 project=instance,
27 filename=filename,
28 created_by=instance.created_by,
29 )
30
31 # Try to init the .fossil file on disk
32 try:
33 dated_at:
34 try:
35 cli = FossilCLI()
36 = FossilCLI()
37 if cli.icli.init(rrepo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0
38 isk else 0
39 repo.save(update_fields=["file_size_bytes", "updated_atelse:
40 logger.except Exception:
41 ept Exception:
42 logger.
--- a/fossil/tasks.py
+++ b/fossil/tasks.py
@@ -0,0 +1,68 @@
1
+"""Celery tasks for Fossil repository management."""
2
+
3
+import hashlib
4
+import logging
5
+
6
+from celery import shared_task
7
+from django.core.files.base import ContentFile
8
+
9
+logger = logging.getLogger(__name__)
10
+
11
+
12
+@shared_task(name="fossil.sync_metadata")
13
+def sync_repository_metadata():
14
+ """Update metadata for all FossilRepository records from disk."""
15
+ from fossil.models import FossilRepository
16
+ from fossil.reader import FossilReader
17
+
18
+ for repo in FossilRepository.objects.all():
19
+ if not repo.exists_on_disk:
20
+ continue
21
+ try:
22
+ repo.file_size_bytes = repo.full_path.stat().st_size
23
+ with FossilReader(repo.full_path) as reader:
24
+ repo.checkin_count = reader.get_checkin_count()
25
+ timeline = reader.get_timeline(limit=1)
26
+ if timeline:
27
+ repo.last_checkin_at = timeline[0].timestamp
28
+ repo.fossil_project_code = reader.get_project_code()
29
+ repo.save(update_fields=["file_size_bytes", "checkin_count", "last_checkin_at", "fossil_project_code", "updated_at", "version"])
30
+ except Exception:
31
+ logger.exception("Failed to sync metadata for %s", repo.filename)
32
+
33
+
34
+@shared_task(name="fossil.create_snapshot")
35
+def create_snapshot(repository_id: int, note: str = ""):
36
+ """Create a FossilSnapshot if FOSSIL_STORE_IN_DB is enabled."""
37
+ from constance import config
38
+
39
+ if not config.FOSSIL_STORE_IN_DB:
40
+ return
41
+
42
+ from fossil.models import FossilRepository, FossilSnapshot
43
+
44
+ try:
45
+ repo = FossilRepository.objects.get(pk=repository_id)
46
+ except FossilRepository.DoesNotExist:
47
+ return
48
+
49
+ if not repo.exists_on_disk:
50
+ return
51
+
52
+ data = repo.full_path.read_bytes()
53
+ sha = hashlib.sha256(data).hexdigest()
54
+
55
+ # Skip if latest snapshot has same hash
56
+ latest = repo.snapshots.first()
57
+ if latest and latest.fossil_hash == sha:
58
+ return
59
+
60
+ snapshot = FossilSnapshot(
61
+ repository=repo,
62
+ file_size_bytes=len(data),
63
+ fossil_hash=sha,
64
+ note=note,
65
+ created_by=repo.created_by,
66
+ )
67
+ snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True)
68
+ logger.info("Created snapshot for %s (hash: %s)", repo.filename, sha[:8])
--- a/fossil/tasks.py
+++ b/fossil/tasks.py
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/tasks.py
+++ b/fossil/tasks.py
@@ -0,0 +1,68 @@
1 """Celery tasks for Fossil repository management."""
2
3 import hashlib
4 import logging
5
6 from celery import shared_task
7 from django.core.files.base import ContentFile
8
9 logger = logging.getLogger(__name__)
10
11
12 @shared_task(name="fossil.sync_metadata")
13 def sync_repository_metadata():
14 """Update metadata for all FossilRepository records from disk."""
15 from fossil.models import FossilRepository
16 from fossil.reader import FossilReader
17
18 for repo in FossilRepository.objects.all():
19 if not repo.exists_on_disk:
20 continue
21 try:
22 repo.file_size_bytes = repo.full_path.stat().st_size
23 with FossilReader(repo.full_path) as reader:
24 repo.checkin_count = reader.get_checkin_count()
25 timeline = reader.get_timeline(limit=1)
26 if timeline:
27 repo.last_checkin_at = timeline[0].timestamp
28 repo.fossil_project_code = reader.get_project_code()
29 repo.save(update_fields=["file_size_bytes", "checkin_count", "last_checkin_at", "fossil_project_code", "updated_at", "version"])
30 except Exception:
31 logger.exception("Failed to sync metadata for %s", repo.filename)
32
33
34 @shared_task(name="fossil.create_snapshot")
35 def create_snapshot(repository_id: int, note: str = ""):
36 """Create a FossilSnapshot if FOSSIL_STORE_IN_DB is enabled."""
37 from constance import config
38
39 if not config.FOSSIL_STORE_IN_DB:
40 return
41
42 from fossil.models import FossilRepository, FossilSnapshot
43
44 try:
45 repo = FossilRepository.objects.get(pk=repository_id)
46 except FossilRepository.DoesNotExist:
47 return
48
49 if not repo.exists_on_disk:
50 return
51
52 data = repo.full_path.read_bytes()
53 sha = hashlib.sha256(data).hexdigest()
54
55 # Skip if latest snapshot has same hash
56 latest = repo.snapshots.first()
57 if latest and latest.fossil_hash == sha:
58 return
59
60 snapshot = FossilSnapshot(
61 repository=repo,
62 file_size_bytes=len(data),
63 fossil_hash=sha,
64 note=note,
65 created_by=repo.created_by,
66 )
67 snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True)
68 logger.info("Created snapshot for %s (hash: %s)", repo.filename, sha[:8])
--- a/fossil/urls.py
+++ b/fossil/urls.py
@@ -0,0 +1,17 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "fossil"
6
+
7
+urlpatterns = [
8
+ path("code/", views.code_browser, name="code"),
9
+ path("code/<path:filepath>", views.code_file, name="code_file"),
10
+ path("timeline/", views.timeline, name="timeline"),
11
+ path("tickets/", views.ticket_list, name="tickets"),
12
+ path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"),
13
+ path("wiki/", views.wiki_list, name="wiki"),
14
+ path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
15
+ path("forum/", views.forum_list, name="forum"),
16
+ path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"),
17
+]
--- a/fossil/urls.py
+++ b/fossil/urls.py
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/urls.py
+++ b/fossil/urls.py
@@ -0,0 +1,17 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "fossil"
6
7 urlpatterns = [
8 path("code/", views.code_browser, name="code"),
9 path("code/<path:filepath>", views.code_file, name="code_file"),
10 path("timeline/", views.timeline, name="timeline"),
11 path("tickets/", views.ticket_list, name="tickets"),
12 path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"),
13 path("wiki/", views.wiki_list, name="wiki"),
14 path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"),
15 path("forum/", views.forum_list, name="forum"),
16 path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"),
17 ]
--- a/fossil/views.py
+++ b/fossil/views.py
@@ -0,0 +1,426 @@
1
+import markdown as md
2
+from django.contrib.auth.decorators import login_required
3
+from django.http import Http404
4
+from django.shortcuts import get_object_or_404, render
5
+from django.utils.safestring import mark_safe
6
+
7
+from core.permissions import P
8
+from projects.models import Project
9
+
10
+from .models import FossilRepository
11
+from .reader import FossilReader
12
+
13
+
14
+def _get_repo_and_reader(slug):
15
+ """Return (project, fossil_repo, reader) or raise 404."""
16
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
17
+ fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
18
+ if not fossil_repo.exists_on_disk:
19
+ raise Http404("Repository file not found on disk")
20
+ reader = FossilReader(fossil_repo.full_path)
21
+ return project, fossil_repo, reader
22
+
23
+
24
+# --- Code Browser ---
25
+
26
+
27
+@login_required
28
+def code_browser(request, slug):
29
+ P.PROJECT_VIEW.check(request.user)
30
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
31
+
32
+ with reader:
33
+ checkin_uuid = reader.get_latest_checkin_uuid()
34
+ files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
35
+ metadata = reader.get_metadata()
36
+ latest_commit = reader.get_timeline(limit=1, event_type="ci")
37
+
38
+ # Build directory tree from flat file list
39
+ tree = _build_file_tree(files)
40
+
41
+ if request.headers.get("HX-Request"):
42
+ return render(request, "fossil/partials/file_tree.html", {"tree": tree, "project": project})
43
+
44
+ return render(
45
+ request,
46
+ "fossil/code_browser.html",
47
+ {
48
+ "project": project,
49
+ "fossil_repo": fossil_repo,
50
+ "tree": tree,
51
+ "checkin_uuid": checkin_uuid,
52
+ "metadata": metadata,
53
+ "latest_commit": latest_commit[0] if latest_commit else None,
54
+ "active_tab": "code",
55
+ },
56
+ )
57
+
58
+
59
+@login_required
60
+def code_file(request, slug, filepath):
61
+ P.PROJECT_VIEW.check(request.user)
62
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
63
+
64
+ with reader:
65
+ checkin_uuid = reader.get_latest_checkin_uuid()
66
+ files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
67
+
68
+ # Find the file by path
69
+ target = None
70
+ for f in files:
71
+ if f.name == filepath:
72
+ target = f
73
+ break
74
+
75
+ if not target:
76
+ raise Http404(f"File not found: {filepath}")
77
+
78
+ content_bytes = reader.get_file_content(target.uuid)
79
+
80
+ # Try to decode as text
81
+ try:
82
+ content = content_bytes.decode("utf-8")
83
+ is_binary = False
84
+ except UnicodeDecodeError:
85
+ content = f"Binary file ({len(content_bytes)} bytes)"
86
+ is_binary = True
87
+
88
+ # Determine language for syntax highlighting
89
+ ext = filepath.rsplit(".", 1)[-1] if "." in filepath else ""
90
+
91
+ return render(
92
+ request,
93
+ "fossil/code_file.html",
94
+ {
95
+ "project": project,
96
+ "fossil_repo": fossil_repo,
97
+ "filepath": filepath,
98
+ "content": content,
99
+ "is_binary": is_binary,
100
+ "language": ext,
101
+ "active_tab": "code",
102
+ },
103
+ )
104
+
105
+
106
+# --- Timeline ---
107
+
108
+
109
+@login_required
110
+def timeline(request, slug):
111
+ P.PROJECT_VIEW.check(request.user)
112
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
113
+
114
+ event_type = request.GET.get("type", "")
115
+ page = int(request.GET.get("page", "1"))
116
+ per_page = 50
117
+ offset = (page - 1) * per_page
118
+
119
+ with reader:
120
+ entries = reader.get_timeline(limit=per_page, offset=offset, event_type=event_type or None)
121
+
122
+ # Compute graph data for template
123
+ graph_entries = _compute_dag_graph(entries)
124
+
125
+ if request.headers.get("HX-Request"):
126
+ return render(request, "fossil/partials/timeline_entries.html", {"entries": graph_entries, "project": project})
127
+
128
+ return render(
129
+ request,
130
+ "fossil/timeline.html",
131
+ {
132
+ "project": project,
133
+ "fossil_repo": fossil_repo,
134
+ "entries": graph_entries,
135
+ "event_type": event_type,
136
+ "page": page,
137
+ "active_tab": "timeline",
138
+ },
139
+ )
140
+
141
+
142
+# --- Tickets ---
143
+
144
+
145
+@login_required
146
+def ticket_list(request, slug):
147
+ P.PROJECT_VIEW.check(request.user)
148
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
149
+
150
+ status_filter = request.GET.get("status", "")
151
+ search = request.GET.get("search", "").strip()
152
+
153
+ with reader:
154
+ tickets = reader.get_tickets(status=status_filter or None)
155
+
156
+ if search:
157
+ tickets = [t for t in tickets if search.lower() in t.title.lower()]
158
+
159
+ if request.headers.get("HX-Request"):
160
+ return render(request, "fossil/partials/ticket_table.html", {"tickets": tickets, "project": project})
161
+
162
+ return render(
163
+ request,
164
+ "fossil/ticket_list.html",
165
+ {
166
+ "project": project,
167
+ "fossil_repo": fossil_repo,
168
+ "tickets": tickets,
169
+ "status_filter": status_filter,
170
+ "search": search,
171
+ "active_tab": "tickets",
172
+ },
173
+ )
174
+
175
+
176
+@login_required
177
+def ticket_detail(request, slug, ticket_uuid):
178
+ P.PROJECT_VIEW.check(request.user)
179
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
180
+
181
+ with reader:
182
+ ticket = reader.get_ticket_detail(ticket_uuid)
183
+
184
+ if not ticket:
185
+ raise Http404("Ticket not found")
186
+
187
+ return render(
188
+ request,
189
+ "fossil/ticket_detail.html",
190
+ {
191
+ "project": project,
192
+ "fossil_repo": fossil_repo,
193
+ "ticket": ticket,
194
+ "active_tab": "tickets",
195
+ },
196
+ )
197
+
198
+
199
+# --- Wiki ---
200
+
201
+
202
+@login_required
203
+def wiki_list(request, slug):
204
+ P.PROJECT_VIEW.check(request.user)
205
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
206
+
207
+ with reader:
208
+ pages = reader.get_wiki_pages()
209
+ home_page = reader.get_wiki_page("Home")
210
+
211
+ home_content_html = ""
212
+ if home_page:
213
+ home_content_html = mark_safe(md.markdown(home_page.content, extensions=["fenced_code", "tables", "toc"]))
214
+
215
+ return render(
216
+ request,
217
+ "fossil/wiki_list.html",
218
+ {
219
+ "project": project,
220
+ "fossil_repo": fossil_repo,
221
+ "pages": pages,
222
+ "home_page": home_page,
223
+ "home_content_html": home_content_html,
224
+ "active_tab": "wiki",
225
+ },
226
+ )
227
+
228
+
229
+@login_required
230
+def wiki_page(request, slug, page_name):
231
+ P.PROJECT_VIEW.check(request.user)
232
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
233
+
234
+ with reader:
235
+ page = reader.get_wiki_page(page_name)
236
+ all_pages = reader.get_wiki_pages()
237
+
238
+ if not page:
239
+ raise Http404(f"Wiki page not found: {page_name}")
240
+
241
+ content_html = mark_safe(md.markdown(page.content, extensions=["fenced_code", "tables", "toc"]))
242
+
243
+ return render(
244
+ request,
245
+ "fossil/wiki_page.html",
246
+ {
247
+ "project": project,
248
+ "fossil_repo": fossil_repo,
249
+ "page": page,
250
+ "all_pages": all_pages,
251
+ "content_html": content_html,
252
+ "active_tab": "wiki",
253
+ },
254
+ )
255
+
256
+
257
+# --- Forum ---
258
+
259
+
260
+@login_required
261
+def forum_list(request, slug):
262
+ P.PROJECT_VIEW.check(request.user)
263
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
264
+
265
+ with reader:
266
+ posts = reader.get_forum_posts()
267
+
268
+ return render(
269
+ request,
270
+ "fossil/forum_list.html",
271
+ {
272
+ "project": project,
273
+ "fossil_repo": fossil_repo,
274
+ "posts": posts,
275
+ "active_tab": "forum",
276
+ },
277
+ )
278
+
279
+
280
+@login_required
281
+def forum_thread(request, slug, thread_uuid):
282
+ P.PROJECT_VIEW.check(request.user)
283
+ project, fossil_repo, reader = _get_repo_and_reader(slug)
284
+
285
+ with reader:
286
+ posts = reader.get_forum_thread(thread_uuid)
287
+
288
+ if not posts:
289
+ raise Http404("Forum thread not found")
290
+
291
+ return render(
292
+ request,
293
+ "fossil/forum_thread.html",
294
+ {
295
+ "project": project,
296
+ "fossil_repo": fossil_repo,
297
+ "posts": posts,
298
+ "thread_uuid": thread_uuid,
299
+ "active_tab": "forum",
300
+ },
301
+ )
302
+
303
+
304
+# --- Helpers ---
305
+
306
+
307
+def _build_file_tree(files):
308
+ """Build a flat sorted list for the top-level directory view (like GitHub).
309
+
310
+ Shows directories and files at the root level only. Directories are sorted first.
311
+ Each directory gets the most recent commit info from its children.
312
+ """
313
+ dirs = {} # dir_name -> most recent file entry
314
+ root_files = []
315
+
316
+ for f in files:
317
+ # Skip files with characters that break URL routing
318
+ if "\n" in f.name or "\r" in f.name or "\x00" in f.name:
319
+ continue
320
+ parts = f.name.split("/")
321
+ if len(parts) > 1:
322
+ # File is inside a directory
323
+ dir_name = parts[0]
324
+ if dir_name not in dirs or (
325
+ f.last_commit_time and (not dirs[dir_name].last_commit_time or f.last_commit_time > dirs[dir_name].last_commit_time)
326
+ ):
327
+ dirs[dir_name] = f
328
+ else:
329
+ root_files.append(f)
330
+
331
+ entries = []
332
+ # Directories first (sorted)
333
+ for dir_name in sorted(dirs):
334
+ f = dirs[dir_name]
335
+ entries.append(
336
+ {
337
+ "name": dir_name,
338
+ "path": dir_name,
339
+ "is_dir": True,
340
+ "commit_message": f.last_commit_message,
341
+ "commit_time": f.last_commit_time,
342
+ }
343
+ )
344
+ # Then files (sorted)
345
+ for f in sorted(root_files, key=lambda x: x.name):
346
+ entries.append(
347
+ {
348
+ "name": f.name,
349
+ "path": f.name,
350
+ "is_dir": False,
351
+ "file": f,
352
+ "commit_message": f.last_commit_message,
353
+ "commit_time": f.last_commit_time,
354
+ }
355
+ )
356
+
357
+ return entries
358
+
359
+
360
+def _compute_dag_graph(entries):
361
+ """Compute DAG graph positions for timeline entries.
362
+
363
+ Returns a list of dicts wrapping each entry with graph rendering data:
364
+ - node_x: pixel x position of the node
365
+ - lines: list of (x1, x2) connections to draw between this row and the next
366
+ """
367
+ rail_pitch = 16 # pixels between rails
368
+ rail_offset = 20 # left margin
369
+
370
+ # Build rid-to-index lookup for connecting lines
371
+ rid_to_idx = {}
372
+ for i, entry in enumerate(entries):
373
+ rid_to_idx[entry.rid] = i
374
+
375
+ result = []
376
+ for i, entry in enumerate(entries):
377
+ rail = max(entry.rail, 0) if entry.rail >= 0 else 0
378
+ node_x = rail_offset + rail * rail_pitch
379
+
380
+ # Determine what vertical lines to draw through this row
381
+ # Active rails: any branch that has entries above and below this point
382
+ active_rails = set()
383
+
384
+ # The current entry's rail is active if it has a parent below
385
+ if entry.event_type == "ci" and entry.parent_rid in rid_to_idx:
386
+ parent_idx = rid_to_idx[entry.parent_rid]
387
+ if parent_idx > i: # parent is below in the list (older)
388
+ active_rails.add(rail)
389
+
390
+ # Check if any entries above connect through this row to entries below
391
+ for j in range(i):
392
+ prev = entries[j]
393
+ if prev.event_type == "ci" and prev.parent_rid in rid_to_idx:
394
+ parent_idx = rid_to_idx[prev.parent_rid]
395
+ if parent_idx > i: # parent is below this row
396
+ prev_rail = max(prev.rail, 0)
397
+ active_rails.add(prev_rail)
398
+
399
+ # Compute line segments as pixel positions
400
+ lines = [{"x": rail_offset + r * rail_pitch} for r in sorted(active_rails)]
401
+
402
+ # Connection from this node's rail to parent's rail (if different = branch/merge line)
403
+ connector = None
404
+ if entry.event_type == "ci" and entry.parent_rid in rid_to_idx:
405
+ parent_idx = rid_to_idx[entry.parent_rid]
406
+ if parent_idx == i + 1: # immediate next entry
407
+ parent_rail = max(entries[parent_idx].rail, 0)
408
+ if parent_rail != rail:
409
+ parent_x = rail_offset + parent_rail * rail_pitch
410
+ connector = {
411
+ "left": min(node_x, parent_x),
412
+ "width": abs(node_x - parent_x),
413
+ }
414
+
415
+ max_rail = max((e.rail for e in entries if e.rail >= 0), default=0)
416
+ result.append(
417
+ {
418
+ "entry": entry,
419
+ "node_x": node_x,
420
+ "lines": lines,
421
+ "connector": connector,
422
+ "graph_width": rail_offset + (max_rail + 2) * rail_pitch,
423
+ }
424
+ )
425
+
426
+ return result
--- a/fossil/views.py
+++ b/fossil/views.py
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/views.py
+++ b/fossil/views.py
@@ -0,0 +1,426 @@
1 import markdown as md
2 from django.contrib.auth.decorators import login_required
3 from django.http import Http404
4 from django.shortcuts import get_object_or_404, render
5 from django.utils.safestring import mark_safe
6
7 from core.permissions import P
8 from projects.models import Project
9
10 from .models import FossilRepository
11 from .reader import FossilReader
12
13
14 def _get_repo_and_reader(slug):
15 """Return (project, fossil_repo, reader) or raise 404."""
16 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
17 fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
18 if not fossil_repo.exists_on_disk:
19 raise Http404("Repository file not found on disk")
20 reader = FossilReader(fossil_repo.full_path)
21 return project, fossil_repo, reader
22
23
24 # --- Code Browser ---
25
26
27 @login_required
28 def code_browser(request, slug):
29 P.PROJECT_VIEW.check(request.user)
30 project, fossil_repo, reader = _get_repo_and_reader(slug)
31
32 with reader:
33 checkin_uuid = reader.get_latest_checkin_uuid()
34 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
35 metadata = reader.get_metadata()
36 latest_commit = reader.get_timeline(limit=1, event_type="ci")
37
38 # Build directory tree from flat file list
39 tree = _build_file_tree(files)
40
41 if request.headers.get("HX-Request"):
42 return render(request, "fossil/partials/file_tree.html", {"tree": tree, "project": project})
43
44 return render(
45 request,
46 "fossil/code_browser.html",
47 {
48 "project": project,
49 "fossil_repo": fossil_repo,
50 "tree": tree,
51 "checkin_uuid": checkin_uuid,
52 "metadata": metadata,
53 "latest_commit": latest_commit[0] if latest_commit else None,
54 "active_tab": "code",
55 },
56 )
57
58
59 @login_required
60 def code_file(request, slug, filepath):
61 P.PROJECT_VIEW.check(request.user)
62 project, fossil_repo, reader = _get_repo_and_reader(slug)
63
64 with reader:
65 checkin_uuid = reader.get_latest_checkin_uuid()
66 files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else []
67
68 # Find the file by path
69 target = None
70 for f in files:
71 if f.name == filepath:
72 target = f
73 break
74
75 if not target:
76 raise Http404(f"File not found: {filepath}")
77
78 content_bytes = reader.get_file_content(target.uuid)
79
80 # Try to decode as text
81 try:
82 content = content_bytes.decode("utf-8")
83 is_binary = False
84 except UnicodeDecodeError:
85 content = f"Binary file ({len(content_bytes)} bytes)"
86 is_binary = True
87
88 # Determine language for syntax highlighting
89 ext = filepath.rsplit(".", 1)[-1] if "." in filepath else ""
90
91 return render(
92 request,
93 "fossil/code_file.html",
94 {
95 "project": project,
96 "fossil_repo": fossil_repo,
97 "filepath": filepath,
98 "content": content,
99 "is_binary": is_binary,
100 "language": ext,
101 "active_tab": "code",
102 },
103 )
104
105
106 # --- Timeline ---
107
108
109 @login_required
110 def timeline(request, slug):
111 P.PROJECT_VIEW.check(request.user)
112 project, fossil_repo, reader = _get_repo_and_reader(slug)
113
114 event_type = request.GET.get("type", "")
115 page = int(request.GET.get("page", "1"))
116 per_page = 50
117 offset = (page - 1) * per_page
118
119 with reader:
120 entries = reader.get_timeline(limit=per_page, offset=offset, event_type=event_type or None)
121
122 # Compute graph data for template
123 graph_entries = _compute_dag_graph(entries)
124
125 if request.headers.get("HX-Request"):
126 return render(request, "fossil/partials/timeline_entries.html", {"entries": graph_entries, "project": project})
127
128 return render(
129 request,
130 "fossil/timeline.html",
131 {
132 "project": project,
133 "fossil_repo": fossil_repo,
134 "entries": graph_entries,
135 "event_type": event_type,
136 "page": page,
137 "active_tab": "timeline",
138 },
139 )
140
141
142 # --- Tickets ---
143
144
145 @login_required
146 def ticket_list(request, slug):
147 P.PROJECT_VIEW.check(request.user)
148 project, fossil_repo, reader = _get_repo_and_reader(slug)
149
150 status_filter = request.GET.get("status", "")
151 search = request.GET.get("search", "").strip()
152
153 with reader:
154 tickets = reader.get_tickets(status=status_filter or None)
155
156 if search:
157 tickets = [t for t in tickets if search.lower() in t.title.lower()]
158
159 if request.headers.get("HX-Request"):
160 return render(request, "fossil/partials/ticket_table.html", {"tickets": tickets, "project": project})
161
162 return render(
163 request,
164 "fossil/ticket_list.html",
165 {
166 "project": project,
167 "fossil_repo": fossil_repo,
168 "tickets": tickets,
169 "status_filter": status_filter,
170 "search": search,
171 "active_tab": "tickets",
172 },
173 )
174
175
176 @login_required
177 def ticket_detail(request, slug, ticket_uuid):
178 P.PROJECT_VIEW.check(request.user)
179 project, fossil_repo, reader = _get_repo_and_reader(slug)
180
181 with reader:
182 ticket = reader.get_ticket_detail(ticket_uuid)
183
184 if not ticket:
185 raise Http404("Ticket not found")
186
187 return render(
188 request,
189 "fossil/ticket_detail.html",
190 {
191 "project": project,
192 "fossil_repo": fossil_repo,
193 "ticket": ticket,
194 "active_tab": "tickets",
195 },
196 )
197
198
199 # --- Wiki ---
200
201
202 @login_required
203 def wiki_list(request, slug):
204 P.PROJECT_VIEW.check(request.user)
205 project, fossil_repo, reader = _get_repo_and_reader(slug)
206
207 with reader:
208 pages = reader.get_wiki_pages()
209 home_page = reader.get_wiki_page("Home")
210
211 home_content_html = ""
212 if home_page:
213 home_content_html = mark_safe(md.markdown(home_page.content, extensions=["fenced_code", "tables", "toc"]))
214
215 return render(
216 request,
217 "fossil/wiki_list.html",
218 {
219 "project": project,
220 "fossil_repo": fossil_repo,
221 "pages": pages,
222 "home_page": home_page,
223 "home_content_html": home_content_html,
224 "active_tab": "wiki",
225 },
226 )
227
228
229 @login_required
230 def wiki_page(request, slug, page_name):
231 P.PROJECT_VIEW.check(request.user)
232 project, fossil_repo, reader = _get_repo_and_reader(slug)
233
234 with reader:
235 page = reader.get_wiki_page(page_name)
236 all_pages = reader.get_wiki_pages()
237
238 if not page:
239 raise Http404(f"Wiki page not found: {page_name}")
240
241 content_html = mark_safe(md.markdown(page.content, extensions=["fenced_code", "tables", "toc"]))
242
243 return render(
244 request,
245 "fossil/wiki_page.html",
246 {
247 "project": project,
248 "fossil_repo": fossil_repo,
249 "page": page,
250 "all_pages": all_pages,
251 "content_html": content_html,
252 "active_tab": "wiki",
253 },
254 )
255
256
257 # --- Forum ---
258
259
260 @login_required
261 def forum_list(request, slug):
262 P.PROJECT_VIEW.check(request.user)
263 project, fossil_repo, reader = _get_repo_and_reader(slug)
264
265 with reader:
266 posts = reader.get_forum_posts()
267
268 return render(
269 request,
270 "fossil/forum_list.html",
271 {
272 "project": project,
273 "fossil_repo": fossil_repo,
274 "posts": posts,
275 "active_tab": "forum",
276 },
277 )
278
279
280 @login_required
281 def forum_thread(request, slug, thread_uuid):
282 P.PROJECT_VIEW.check(request.user)
283 project, fossil_repo, reader = _get_repo_and_reader(slug)
284
285 with reader:
286 posts = reader.get_forum_thread(thread_uuid)
287
288 if not posts:
289 raise Http404("Forum thread not found")
290
291 return render(
292 request,
293 "fossil/forum_thread.html",
294 {
295 "project": project,
296 "fossil_repo": fossil_repo,
297 "posts": posts,
298 "thread_uuid": thread_uuid,
299 "active_tab": "forum",
300 },
301 )
302
303
304 # --- Helpers ---
305
306
307 def _build_file_tree(files):
308 """Build a flat sorted list for the top-level directory view (like GitHub).
309
310 Shows directories and files at the root level only. Directories are sorted first.
311 Each directory gets the most recent commit info from its children.
312 """
313 dirs = {} # dir_name -> most recent file entry
314 root_files = []
315
316 for f in files:
317 # Skip files with characters that break URL routing
318 if "\n" in f.name or "\r" in f.name or "\x00" in f.name:
319 continue
320 parts = f.name.split("/")
321 if len(parts) > 1:
322 # File is inside a directory
323 dir_name = parts[0]
324 if dir_name not in dirs or (
325 f.last_commit_time and (not dirs[dir_name].last_commit_time or f.last_commit_time > dirs[dir_name].last_commit_time)
326 ):
327 dirs[dir_name] = f
328 else:
329 root_files.append(f)
330
331 entries = []
332 # Directories first (sorted)
333 for dir_name in sorted(dirs):
334 f = dirs[dir_name]
335 entries.append(
336 {
337 "name": dir_name,
338 "path": dir_name,
339 "is_dir": True,
340 "commit_message": f.last_commit_message,
341 "commit_time": f.last_commit_time,
342 }
343 )
344 # Then files (sorted)
345 for f in sorted(root_files, key=lambda x: x.name):
346 entries.append(
347 {
348 "name": f.name,
349 "path": f.name,
350 "is_dir": False,
351 "file": f,
352 "commit_message": f.last_commit_message,
353 "commit_time": f.last_commit_time,
354 }
355 )
356
357 return entries
358
359
360 def _compute_dag_graph(entries):
361 """Compute DAG graph positions for timeline entries.
362
363 Returns a list of dicts wrapping each entry with graph rendering data:
364 - node_x: pixel x position of the node
365 - lines: list of (x1, x2) connections to draw between this row and the next
366 """
367 rail_pitch = 16 # pixels between rails
368 rail_offset = 20 # left margin
369
370 # Build rid-to-index lookup for connecting lines
371 rid_to_idx = {}
372 for i, entry in enumerate(entries):
373 rid_to_idx[entry.rid] = i
374
375 result = []
376 for i, entry in enumerate(entries):
377 rail = max(entry.rail, 0) if entry.rail >= 0 else 0
378 node_x = rail_offset + rail * rail_pitch
379
380 # Determine what vertical lines to draw through this row
381 # Active rails: any branch that has entries above and below this point
382 active_rails = set()
383
384 # The current entry's rail is active if it has a parent below
385 if entry.event_type == "ci" and entry.parent_rid in rid_to_idx:
386 parent_idx = rid_to_idx[entry.parent_rid]
387 if parent_idx > i: # parent is below in the list (older)
388 active_rails.add(rail)
389
390 # Check if any entries above connect through this row to entries below
391 for j in range(i):
392 prev = entries[j]
393 if prev.event_type == "ci" and prev.parent_rid in rid_to_idx:
394 parent_idx = rid_to_idx[prev.parent_rid]
395 if parent_idx > i: # parent is below this row
396 prev_rail = max(prev.rail, 0)
397 active_rails.add(prev_rail)
398
399 # Compute line segments as pixel positions
400 lines = [{"x": rail_offset + r * rail_pitch} for r in sorted(active_rails)]
401
402 # Connection from this node's rail to parent's rail (if different = branch/merge line)
403 connector = None
404 if entry.event_type == "ci" and entry.parent_rid in rid_to_idx:
405 parent_idx = rid_to_idx[entry.parent_rid]
406 if parent_idx == i + 1: # immediate next entry
407 parent_rail = max(entries[parent_idx].rail, 0)
408 if parent_rail != rail:
409 parent_x = rail_offset + parent_rail * rail_pitch
410 connector = {
411 "left": min(node_x, parent_x),
412 "width": abs(node_x - parent_x),
413 }
414
415 max_rail = max((e.rail for e in entries if e.rail >= 0), default=0)
416 result.append(
417 {
418 "entry": entry,
419 "node_x": node_x,
420 "lines": lines,
421 "connector": connector,
422 "graph_width": rail_offset + (max_rail + 2) * rail_pitch,
423 }
424 )
425
426 return result
+2 -2
--- items/forms.py
+++ items/forms.py
@@ -1,10 +1,10 @@
11
from django import forms
22
33
from .models import Item
44
5
-tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
5
+tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
66
77
88
class ItemForm(forms.ModelForm):
99
class Meta:
1010
model = Item
@@ -12,7 +12,7 @@
1212
widgets = {
1313
"name": forms.TextInput(attrs={"class": tw, "placeholder": "Item name"}),
1414
"description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
1515
"price": forms.NumberInput(attrs={"class": tw, "step": "0.01", "placeholder": "0.00"}),
1616
"sku": forms.TextInput(attrs={"class": tw, "placeholder": "SKU-001"}),
17
- "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-indigo-600"}),
17
+ "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}),
1818
}
1919
--- items/forms.py
+++ items/forms.py
@@ -1,10 +1,10 @@
1 from django import forms
2
3 from .models import Item
4
5 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
6
7
8 class ItemForm(forms.ModelForm):
9 class Meta:
10 model = Item
@@ -12,7 +12,7 @@
12 widgets = {
13 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Item name"}),
14 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
15 "price": forms.NumberInput(attrs={"class": tw, "step": "0.01", "placeholder": "0.00"}),
16 "sku": forms.TextInput(attrs={"class": tw, "placeholder": "SKU-001"}),
17 "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-indigo-600"}),
18 }
19
--- items/forms.py
+++ items/forms.py
@@ -1,10 +1,10 @@
1 from django import forms
2
3 from .models import Item
4
5 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
6
7
8 class ItemForm(forms.ModelForm):
9 class Meta:
10 model = Item
@@ -12,7 +12,7 @@
12 widgets = {
13 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Item name"}),
14 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
15 "price": forms.NumberInput(attrs={"class": tw, "step": "0.01", "placeholder": "0.00"}),
16 "sku": forms.TextInput(attrs={"class": tw, "placeholder": "SKU-001"}),
17 "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}),
18 }
19
--- organization/admin.py
+++ organization/admin.py
@@ -1,10 +1,10 @@
11
from django.contrib import admin
22
33
from core.admin import BaseCoreAdmin
44
5
-from .models import Organization, OrganizationMember
5
+from .models import Organization, OrganizationMember, Team
66
77
88
class OrganizationMemberInline(admin.TabularInline):
99
model = OrganizationMember
1010
extra = 0
@@ -15,11 +15,18 @@
1515
class OrganizationAdmin(BaseCoreAdmin):
1616
list_display = ("name", "slug", "website", "created_at")
1717
search_fields = ("name", "slug")
1818
inlines = [OrganizationMemberInline]
1919
20
+
21
+@admin.register(Team)
22
+class TeamAdmin(BaseCoreAdmin):
23
+ list_display = ("name", "slug", "organization", "created_at")
24
+ search_fields = ("name", "slug")
25
+ filter_horizontal = ("members",)
26
+
2027
2128
@admin.register(OrganizationMember)
2229
class OrganizationMemberAdmin(BaseCoreAdmin):
2330
list_display = ("member", "organization", "is_active", "created_at")
2431
list_filter = ("is_active",)
2532
raw_id_fields = ("member", "organization")
2633
2734
ADDED organization/forms.py
2835
ADDED organization/migrations/0002_historicalteam_team.py
--- organization/admin.py
+++ organization/admin.py
@@ -1,10 +1,10 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import Organization, OrganizationMember
6
7
8 class OrganizationMemberInline(admin.TabularInline):
9 model = OrganizationMember
10 extra = 0
@@ -15,11 +15,18 @@
15 class OrganizationAdmin(BaseCoreAdmin):
16 list_display = ("name", "slug", "website", "created_at")
17 search_fields = ("name", "slug")
18 inlines = [OrganizationMemberInline]
19
 
 
 
 
 
 
 
20
21 @admin.register(OrganizationMember)
22 class OrganizationMemberAdmin(BaseCoreAdmin):
23 list_display = ("member", "organization", "is_active", "created_at")
24 list_filter = ("is_active",)
25 raw_id_fields = ("member", "organization")
26
27 DDED organization/forms.py
28 DDED organization/migrations/0002_historicalteam_team.py
--- organization/admin.py
+++ organization/admin.py
@@ -1,10 +1,10 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import Organization, OrganizationMember, Team
6
7
8 class OrganizationMemberInline(admin.TabularInline):
9 model = OrganizationMember
10 extra = 0
@@ -15,11 +15,18 @@
15 class OrganizationAdmin(BaseCoreAdmin):
16 list_display = ("name", "slug", "website", "created_at")
17 search_fields = ("name", "slug")
18 inlines = [OrganizationMemberInline]
19
20
21 @admin.register(Team)
22 class TeamAdmin(BaseCoreAdmin):
23 list_display = ("name", "slug", "organization", "created_at")
24 search_fields = ("name", "slug")
25 filter_horizontal = ("members",)
26
27
28 @admin.register(OrganizationMember)
29 class OrganizationMemberAdmin(BaseCoreAdmin):
30 list_display = ("member", "organization", "is_active", "created_at")
31 list_filter = ("is_active",)
32 raw_id_fields = ("member", "organization")
33
34 DDED organization/forms.py
35 DDED organization/migrations/0002_historicalteam_team.py
--- a/organization/forms.py
+++ b/organization/forms.py
@@ -0,0 +1,55 @@
1
+from django import forms
2
+from django.contrib.auth.models import User
3
+
4
+from .mTeam
5
+
6
+tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
7
+
8
+
9
+class OrganizationSettingsForm(forms.ModelForm):
10
+ class Meta:
11
+ model = Organization
12
+ fields = ["name", "description", "website"]
13
+ widgets = {
14
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "Organization name"}),
15
+ "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
16
+ "website": forms.URLInput(attrs={"class": tw, "placeholder": "https://example.com"}),
17
+ }
18
+
19
+
20
+class MemberAddForm(forms.Form):
21
+ user = forms.ModelChoiceField(
22
+ queryset=User.objects.none(),
23
+ widget=forms.Select(attrs={"class": tw}),
24
+ label="User",
25
+ )
26
+
27
+ def __init__(self, *args, org=None, **kwargs):
28
+ super().__init__(*args, **kwargs)
29
+ if org:
30
+ existing_member_ids = org.members.filter(deleted_at__isnull=True).values_list("member_id", flat=True)
31
+ self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
32
+
33
+
34
+class TeamForm(forms.ModelForm):
35
+ class Meta:
36
+ model = Team
37
+ fields = ["name", "description"]
38
+ widgets = {
39
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "Team name"}),
40
+ "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
41
+ }
42
+
43
+
44
+class TeamMemberAddForm(forms.Form):
45
+ user = forms.ModelChoiceField(
46
+ queryset=User.objects.none(),
47
+ widget=forms.Select(attrs={"class": tw}),
48
+ label="User",
49
+ )
50
+
51
+ def __init__(self, *args, team=None, **kwargs):
52
+ super().__init__(*args, **kwargs)
53
+ if team:
54
+ existing_member_ids = team.members.values_list("id", flat=True)
55
+ self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
--- a/organization/forms.py
+++ b/organization/forms.py
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/forms.py
+++ b/organization/forms.py
@@ -0,0 +1,55 @@
1 from django import forms
2 from django.contrib.auth.models import User
3
4 from .mTeam
5
6 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
7
8
9 class OrganizationSettingsForm(forms.ModelForm):
10 class Meta:
11 model = Organization
12 fields = ["name", "description", "website"]
13 widgets = {
14 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Organization name"}),
15 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
16 "website": forms.URLInput(attrs={"class": tw, "placeholder": "https://example.com"}),
17 }
18
19
20 class MemberAddForm(forms.Form):
21 user = forms.ModelChoiceField(
22 queryset=User.objects.none(),
23 widget=forms.Select(attrs={"class": tw}),
24 label="User",
25 )
26
27 def __init__(self, *args, org=None, **kwargs):
28 super().__init__(*args, **kwargs)
29 if org:
30 existing_member_ids = org.members.filter(deleted_at__isnull=True).values_list("member_id", flat=True)
31 self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
32
33
34 class TeamForm(forms.ModelForm):
35 class Meta:
36 model = Team
37 fields = ["name", "description"]
38 widgets = {
39 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Team name"}),
40 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
41 }
42
43
44 class TeamMemberAddForm(forms.Form):
45 user = forms.ModelChoiceField(
46 queryset=User.objects.none(),
47 widget=forms.Select(attrs={"class": tw}),
48 label="User",
49 )
50
51 def __init__(self, *args, team=None, **kwargs):
52 super().__init__(*args, **kwargs)
53 if team:
54 existing_member_ids = team.members.values_list("id", flat=True)
55 self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
--- a/organization/migrations/0002_historicalteam_team.py
+++ b/organization/migrations/0002_historicalteam_team.py
@@ -0,0 +1,178 @@
1
+# Generated by Django 5.2.12 on 2026-04-06 01:08
2
+
3
+import uuid
4
+
5
+import django.db.models.deletion
6
+import simple_history.models
7
+from django.conf import settings
8
+from django.db import migrations, models
9
+
10
+
11
+class Migration(migrations.Migration):
12
+ dependencies = [
13
+ ("organization", "0001_initial"),
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name="HistoricalTeam",
20
+ fields=[
21
+ (
22
+ "id",
23
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
24
+ ),
25
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
26
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
27
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
28
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
29
+ (
30
+ "guid",
31
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
32
+ ),
33
+ ("name", models.CharField(max_length=200)),
34
+ ("slug", models.SlugField(max_length=200)),
35
+ ("description", models.TextField(blank=True, default="")),
36
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
37
+ ("history_date", models.DateTimeField(db_index=True)),
38
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
39
+ (
40
+ "history_type",
41
+ models.CharField(
42
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
43
+ max_length=1,
44
+ ),
45
+ ),
46
+ (
47
+ "created_by",
48
+ models.ForeignKey(
49
+ blank=True,
50
+ db_constraint=False,
51
+ null=True,
52
+ on_delete=django.db.models.deletion.DO_NOTHING,
53
+ related_name="+",
54
+ to=settings.AUTH_USER_MODEL,
55
+ ),
56
+ ),
57
+ (
58
+ "deleted_by",
59
+ models.ForeignKey(
60
+ blank=True,
61
+ db_constraint=False,
62
+ null=True,
63
+ on_delete=django.db.models.deletion.DO_NOTHING,
64
+ related_name="+",
65
+ to=settings.AUTH_USER_MODEL,
66
+ ),
67
+ ),
68
+ (
69
+ "history_user",
70
+ models.ForeignKey(
71
+ null=True,
72
+ on_delete=django.db.models.deletion.SET_NULL,
73
+ related_name="+",
74
+ to=settings.AUTH_USER_MODEL,
75
+ ),
76
+ ),
77
+ (
78
+ "organization",
79
+ models.ForeignKey(
80
+ blank=True,
81
+ db_constraint=False,
82
+ null=True,
83
+ on_delete=django.db.models.deletion.DO_NOTHING,
84
+ related_name="+",
85
+ to="organization.organization",
86
+ ),
87
+ ),
88
+ (
89
+ "updated_by",
90
+ models.ForeignKey(
91
+ blank=True,
92
+ db_constraint=False,
93
+ null=True,
94
+ on_delete=django.db.models.deletion.DO_NOTHING,
95
+ related_name="+",
96
+ to=settings.AUTH_USER_MODEL,
97
+ ),
98
+ ),
99
+ ],
100
+ options={
101
+ "verbose_name": "historical team",
102
+ "verbose_name_plural": "historical teams",
103
+ "ordering": ("-history_date", "-history_id"),
104
+ "get_latest_by": ("history_date", "history_id"),
105
+ },
106
+ bases=(simple_history.models.HistoricalChanges, models.Model),
107
+ ),
108
+ migrations.CreateModel(
109
+ name="Team",
110
+ fields=[
111
+ (
112
+ "id",
113
+ models.BigAutoField(
114
+ auto_created=True,
115
+ primary_key=True,
116
+ serialize=False,
117
+ verbose_name="ID",
118
+ ),
119
+ ),
120
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
121
+ ("created_at", models.DateTimeField(auto_now_add=True)),
122
+ ("updated_at", models.DateTimeField(auto_now=True)),
123
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
124
+ (
125
+ "guid",
126
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
127
+ ),
128
+ ("name", models.CharField(max_length=200)),
129
+ ("slug", models.SlugField(max_length=200, unique=True)),
130
+ ("description", models.TextField(blank=True, default="")),
131
+ (
132
+ "created_by",
133
+ models.ForeignKey(
134
+ blank=True,
135
+ null=True,
136
+ on_delete=django.db.models.deletion.SET_NULL,
137
+ related_name="+",
138
+ to=settings.AUTH_USER_MODEL,
139
+ ),
140
+ ),
141
+ (
142
+ "deleted_by",
143
+ models.ForeignKey(
144
+ blank=True,
145
+ null=True,
146
+ on_delete=django.db.models.deletion.SET_NULL,
147
+ related_name="+",
148
+ to=settings.AUTH_USER_MODEL,
149
+ ),
150
+ ),
151
+ (
152
+ "members",
153
+ models.ManyToManyField(blank=True, related_name="teams", to=settings.AUTH_USER_MODEL),
154
+ ),
155
+ (
156
+ "organization",
157
+ models.ForeignKey(
158
+ on_delete=django.db.models.deletion.CASCADE,
159
+ related_name="teams",
160
+ to="organization.organization",
161
+ ),
162
+ ),
163
+ (
164
+ "updated_by",
165
+ models.ForeignKey(
166
+ blank=True,
167
+ null=True,
168
+ on_delete=django.db.models.deletion.SET_NULL,
169
+ related_name="+",
170
+ to=settings.AUTH_USER_MODEL,
171
+ ),
172
+ ),
173
+ ],
174
+ options={
175
+ "ordering": ["name"],
176
+ },
177
+ ),
178
+ ]
--- a/organization/migrations/0002_historicalteam_team.py
+++ b/organization/migrations/0002_historicalteam_team.py
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/migrations/0002_historicalteam_team.py
+++ b/organization/migrations/0002_historicalteam_team.py
@@ -0,0 +1,178 @@
1 # Generated by Django 5.2.12 on 2026-04-06 01:08
2
3 import uuid
4
5 import django.db.models.deletion
6 import simple_history.models
7 from django.conf import settings
8 from django.db import migrations, models
9
10
11 class Migration(migrations.Migration):
12 dependencies = [
13 ("organization", "0001_initial"),
14 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15 ]
16
17 operations = [
18 migrations.CreateModel(
19 name="HistoricalTeam",
20 fields=[
21 (
22 "id",
23 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
24 ),
25 ("version", models.PositiveIntegerField(default=1, editable=False)),
26 ("created_at", models.DateTimeField(blank=True, editable=False)),
27 ("updated_at", models.DateTimeField(blank=True, editable=False)),
28 ("deleted_at", models.DateTimeField(blank=True, null=True)),
29 (
30 "guid",
31 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
32 ),
33 ("name", models.CharField(max_length=200)),
34 ("slug", models.SlugField(max_length=200)),
35 ("description", models.TextField(blank=True, default="")),
36 ("history_id", models.AutoField(primary_key=True, serialize=False)),
37 ("history_date", models.DateTimeField(db_index=True)),
38 ("history_change_reason", models.CharField(max_length=100, null=True)),
39 (
40 "history_type",
41 models.CharField(
42 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
43 max_length=1,
44 ),
45 ),
46 (
47 "created_by",
48 models.ForeignKey(
49 blank=True,
50 db_constraint=False,
51 null=True,
52 on_delete=django.db.models.deletion.DO_NOTHING,
53 related_name="+",
54 to=settings.AUTH_USER_MODEL,
55 ),
56 ),
57 (
58 "deleted_by",
59 models.ForeignKey(
60 blank=True,
61 db_constraint=False,
62 null=True,
63 on_delete=django.db.models.deletion.DO_NOTHING,
64 related_name="+",
65 to=settings.AUTH_USER_MODEL,
66 ),
67 ),
68 (
69 "history_user",
70 models.ForeignKey(
71 null=True,
72 on_delete=django.db.models.deletion.SET_NULL,
73 related_name="+",
74 to=settings.AUTH_USER_MODEL,
75 ),
76 ),
77 (
78 "organization",
79 models.ForeignKey(
80 blank=True,
81 db_constraint=False,
82 null=True,
83 on_delete=django.db.models.deletion.DO_NOTHING,
84 related_name="+",
85 to="organization.organization",
86 ),
87 ),
88 (
89 "updated_by",
90 models.ForeignKey(
91 blank=True,
92 db_constraint=False,
93 null=True,
94 on_delete=django.db.models.deletion.DO_NOTHING,
95 related_name="+",
96 to=settings.AUTH_USER_MODEL,
97 ),
98 ),
99 ],
100 options={
101 "verbose_name": "historical team",
102 "verbose_name_plural": "historical teams",
103 "ordering": ("-history_date", "-history_id"),
104 "get_latest_by": ("history_date", "history_id"),
105 },
106 bases=(simple_history.models.HistoricalChanges, models.Model),
107 ),
108 migrations.CreateModel(
109 name="Team",
110 fields=[
111 (
112 "id",
113 models.BigAutoField(
114 auto_created=True,
115 primary_key=True,
116 serialize=False,
117 verbose_name="ID",
118 ),
119 ),
120 ("version", models.PositiveIntegerField(default=1, editable=False)),
121 ("created_at", models.DateTimeField(auto_now_add=True)),
122 ("updated_at", models.DateTimeField(auto_now=True)),
123 ("deleted_at", models.DateTimeField(blank=True, null=True)),
124 (
125 "guid",
126 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
127 ),
128 ("name", models.CharField(max_length=200)),
129 ("slug", models.SlugField(max_length=200, unique=True)),
130 ("description", models.TextField(blank=True, default="")),
131 (
132 "created_by",
133 models.ForeignKey(
134 blank=True,
135 null=True,
136 on_delete=django.db.models.deletion.SET_NULL,
137 related_name="+",
138 to=settings.AUTH_USER_MODEL,
139 ),
140 ),
141 (
142 "deleted_by",
143 models.ForeignKey(
144 blank=True,
145 null=True,
146 on_delete=django.db.models.deletion.SET_NULL,
147 related_name="+",
148 to=settings.AUTH_USER_MODEL,
149 ),
150 ),
151 (
152 "members",
153 models.ManyToManyField(blank=True, related_name="teams", to=settings.AUTH_USER_MODEL),
154 ),
155 (
156 "organization",
157 models.ForeignKey(
158 on_delete=django.db.models.deletion.CASCADE,
159 related_name="teams",
160 to="organization.organization",
161 ),
162 ),
163 (
164 "updated_by",
165 models.ForeignKey(
166 blank=True,
167 null=True,
168 on_delete=django.db.models.deletion.SET_NULL,
169 related_name="+",
170 to=settings.AUTH_USER_MODEL,
171 ),
172 ),
173 ],
174 options={
175 "ordering": ["name"],
176 },
177 ),
178 ]
--- organization/models.py
+++ organization/models.py
@@ -6,10 +6,21 @@
66
77
class Organization(BaseCoreModel):
88
website = models.URLField(blank=True, default="")
99
groups = models.ManyToManyField(Group, blank=True, related_name="organizations")
1010
11
+ objects = ActiveManager()
12
+ all_objects = models.Manager()
13
+
14
+ class Meta:
15
+ ordering = ["name"]
16
+
17
+
18
+class Team(BaseCoreModel):
19
+ organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="teams")
20
+ members = models.ManyToManyField("auth.User", blank=True, related_name="teams")
21
+
1122
objects = ActiveManager()
1223
all_objects = models.Manager()
1324
1425
class Meta:
1526
ordering = ["name"]
1627
--- organization/models.py
+++ organization/models.py
@@ -6,10 +6,21 @@
6
7 class Organization(BaseCoreModel):
8 website = models.URLField(blank=True, default="")
9 groups = models.ManyToManyField(Group, blank=True, related_name="organizations")
10
 
 
 
 
 
 
 
 
 
 
 
11 objects = ActiveManager()
12 all_objects = models.Manager()
13
14 class Meta:
15 ordering = ["name"]
16
--- organization/models.py
+++ organization/models.py
@@ -6,10 +6,21 @@
6
7 class Organization(BaseCoreModel):
8 website = models.URLField(blank=True, default="")
9 groups = models.ManyToManyField(Group, blank=True, related_name="organizations")
10
11 objects = ActiveManager()
12 all_objects = models.Manager()
13
14 class Meta:
15 ordering = ["name"]
16
17
18 class Team(BaseCoreModel):
19 organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="teams")
20 members = models.ManyToManyField("auth.User", blank=True, related_name="teams")
21
22 objects = ActiveManager()
23 all_objects = models.Manager()
24
25 class Meta:
26 ordering = ["name"]
27
--- organization/tests.py
+++ organization/tests.py
@@ -1,9 +1,9 @@
11
import pytest
22
from django.contrib.auth.models import User
33
4
-from .models import Organization, OrganizationMember
4
+from .models import Organization, OrganizationMember, Team
55
66
77
@pytest.mark.django_db
88
class TestOrganization:
99
def test_create_organization(self):
@@ -31,5 +31,149 @@
3131
OrganizationMember.objects.create(member=admin_user, organization=org)
3232
3333
def test_str_representation(self, admin_user, org):
3434
member = OrganizationMember.objects.get(member=admin_user, organization=org)
3535
assert str(member) == f"{org}/{admin_user}"
36
+
37
+
38
+@pytest.mark.django_db
39
+class TestOrgSettingsViews:
40
+ def test_settings_page_renders(self, admin_client, org):
41
+ response = admin_client.get("/settings/")
42
+ assert response.status_code == 200
43
+ assert org.name in response.content.decode()
44
+
45
+ def test_settings_denied_without_perm(self, no_perm_client, org):
46
+ response = no_perm_client.get("/settings/")
47
+ assert response.status_code == 403
48
+
49
+ def test_settings_edit_renders(self, admin_client, org):
50
+ response = admin_client.get("/settings/edit/")
51
+ assert response.status_code == 200
52
+
53
+ def test_settings_edit_saves(self, admin_client, org):
54
+ response = admin_client.post("/settings/edit/", {"name": "Updated Org", "description": "New desc", "website": ""})
55
+ assert response.status_code == 302
56
+ org.refresh_from_db()
57
+ assert org.name == "Updated Org"
58
+
59
+ def test_settings_edit_denied(self, no_perm_client, org):
60
+ response = no_perm_client.post("/settings/edit/", {"name": "Hacked"})
61
+ assert response.status_code == 403
62
+
63
+
64
+@pytest.mark.django_db
65
+class TestMemberViews:
66
+ def test_member_list_renders(self, admin_client, org):
67
+ response = admin_client.get("/settings/members/")
68
+ assert response.status_code == 200
69
+
70
+ def test_member_list_htmx_returns_partial(self, admin_client, org):
71
+ response = admin_client.get("/settings/members/", HTTP_HX_REQUEST="true")
72
+ assert response.status_code == 200
73
+ assert b"member-table" in response.content
74
+
75
+ def test_member_list_search(self, admin_client, org):
76
+ response = admin_client.get("/settings/members/?search=admin")
77
+ assert response.status_code == 200
78
+
79
+ def test_member_list_denied(self, no_perm_client, org):
80
+ response = no_perm_client.get("/settings/members/")
81
+ assert response.status_code == 403
82
+
83
+ def test_member_add(self, admin_client, org):
84
+ User.objects.create_user(username="newuser", password="x")
85
+ response = admin_client.post("/settings/members/add/", {"user": User.objects.get(username="newuser").id})
86
+ assert response.status_code == 302
87
+ assert OrganizationMember.objects.filter(member__username="newuser", organization=org).exists()
88
+
89
+ def test_member_add_denied(self, no_perm_client, org):
90
+ response = no_perm_client.get("/settings/members/add/")
91
+ assert response.status_code == 403
92
+
93
+ def test_member_remove(self, admin_client, org, admin_user):
94
+ response = admin_client.post(f"/settings/members/{admin_user.username}/remove/")
95
+ assert response.status_code == 302
96
+ membership = OrganizationMember.all_objects.get(member=admin_user, organization=org)
97
+ assert membership.is_deleted
98
+
99
+ def test_member_remove_denied(self, no_perm_client, org, admin_user):
100
+ response = no_perm_client.post(f"/settings/members/{admin_user.username}/remove/")
101
+ assert response.status_code == 403
102
+
103
+
104
+@pytest.mark.django_db
105
+class TestTeamModel:
106
+ def test_create_team(self, org, admin_user):
107
+ team = Team.objects.create(name="Backend", organization=org, created_by=admin_user)
108
+ assert team.slug == "backend"
109
+ assert team.guid is not None
110
+
111
+ def test_soft_delete_team(self, sample_team, admin_user):
112
+ sample_team.soft_delete(user=admin_user)
113
+ assert Team.objects.filter(slug=sample_team.slug).count() == 0
114
+ assert Team.all_objects.filter(slug=sample_team.slug).count() == 1
115
+
116
+
117
+@pytest.mark.django_db
118
+class TestTeamViews:
119
+ def test_team_list_renders(self, admin_client, org, sample_team):
120
+ response = admin_client.get("/settings/teams/")
121
+ assert response.status_code == 200
122
+ assert sample_team.name in response.content.decode()
123
+
124
+ def test_team_list_htmx(self, admin_client, org, sample_team):
125
+ response = admin_client.get("/settings/teams/", HTTP_HX_REQUEST="true")
126
+ assert response.status_code == 200
127
+ assert b"team-table" in response.content
128
+
129
+ def test_team_list_search(self, admin_client, org, sample_team):
130
+ response = admin_client.get("/settings/teams/?search=Core")
131
+ assert response.status_code == 200
132
+
133
+ def test_team_list_denied(self, no_perm_client, org):
134
+ response = no_perm_client.get("/settings/teams/")
135
+ assert response.status_code == 403
136
+
137
+ def test_team_create(self, admin_client, org):
138
+ response = admin_client.post("/settings/teams/create/", {"name": "New Team", "description": "A new team"})
139
+ assert response.status_code == 302
140
+ assert Team.objects.filter(slug="new-team").exists()
141
+
142
+ def test_team_create_denied(self, no_perm_client, org):
143
+ response = no_perm_client.post("/settings/teams/create/", {"name": "Hack Team"})
144
+ assert response.status_code == 403
145
+
146
+ def test_team_detail_renders(self, admin_client, sample_team):
147
+ response = admin_client.get(f"/settings/teams/{sample_team.slug}/")
148
+ assert response.status_code == 200
149
+ assert sample_team.name in response.content.decode()
150
+
151
+ def test_team_update(self, admin_client, sample_team):
152
+ response = admin_client.post(f"/settings/teams/{sample_team.slug}/edit/", {"name": "Updated Team", "description": ""})
153
+ assert response.status_code == 302
154
+ sample_team.refresh_from_db()
155
+ assert sample_team.name == "Updated Team"
156
+
157
+ def test_team_update_denied(self, no_perm_client, sample_team):
158
+ response = no_perm_client.post(f"/settings/teams/{sample_team.slug}/edit/", {"name": "Hacked"})
159
+ assert response.status_code == 403
160
+
161
+ def test_team_delete(self, admin_client, sample_team):
162
+ response = admin_client.post(f"/settings/teams/{sample_team.slug}/delete/")
163
+ assert response.status_code == 302
164
+ assert Team.objects.filter(slug=sample_team.slug).count() == 0
165
+
166
+ def test_team_delete_denied(self, no_perm_client, sample_team):
167
+ response = no_perm_client.post(f"/settings/teams/{sample_team.slug}/delete/")
168
+ assert response.status_code == 403
169
+
170
+ def test_team_member_add(self, admin_client, sample_team):
171
+ new_user = User.objects.create_user(username="teamuser", password="x")
172
+ response = admin_client.post(f"/settings/teams/{sample_team.slug}/members/add/", {"user": new_user.id})
173
+ assert response.status_code == 302
174
+ assert sample_team.members.filter(username="teamuser").exists()
175
+
176
+ def test_team_member_remove(self, admin_client, sample_team, admin_user):
177
+ response = admin_client.post(f"/settings/teams/{sample_team.slug}/members/{admin_user.username}/remove/")
178
+ assert response.status_code == 302
179
+ assert not sample_team.members.filter(username=admin_user.username).exists()
36180
37181
ADDED organization/urls.py
38182
ADDED organization/views.py
39183
ADDED pages/__init__.py
40184
ADDED pages/admin.py
41185
ADDED pages/apps.py
42186
ADDED pages/forms.py
43187
ADDED pages/migrations/0001_initial.py
44188
ADDED pages/migrations/__init__.py
45189
ADDED pages/models.py
46190
ADDED pages/tests.py
47191
ADDED pages/urls.py
48192
ADDED pages/views.py
49193
ADDED projects/__init__.py
50194
ADDED projects/admin.py
51195
ADDED projects/apps.py
52196
ADDED projects/forms.py
53197
ADDED projects/migrations/0001_initial.py
54198
ADDED projects/migrations/__init__.py
55199
ADDED projects/models.py
56200
ADDED projects/tests.py
57201
ADDED projects/urls.py
58202
ADDED projects/views.py
--- organization/tests.py
+++ organization/tests.py
@@ -1,9 +1,9 @@
1 import pytest
2 from django.contrib.auth.models import User
3
4 from .models import Organization, OrganizationMember
5
6
7 @pytest.mark.django_db
8 class TestOrganization:
9 def test_create_organization(self):
@@ -31,5 +31,149 @@
31 OrganizationMember.objects.create(member=admin_user, organization=org)
32
33 def test_str_representation(self, admin_user, org):
34 member = OrganizationMember.objects.get(member=admin_user, organization=org)
35 assert str(member) == f"{org}/{admin_user}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
37 DDED organization/urls.py
38 DDED organization/views.py
39 DDED pages/__init__.py
40 DDED pages/admin.py
41 DDED pages/apps.py
42 DDED pages/forms.py
43 DDED pages/migrations/0001_initial.py
44 DDED pages/migrations/__init__.py
45 DDED pages/models.py
46 DDED pages/tests.py
47 DDED pages/urls.py
48 DDED pages/views.py
49 DDED projects/__init__.py
50 DDED projects/admin.py
51 DDED projects/apps.py
52 DDED projects/forms.py
53 DDED projects/migrations/0001_initial.py
54 DDED projects/migrations/__init__.py
55 DDED projects/models.py
56 DDED projects/tests.py
57 DDED projects/urls.py
58 DDED projects/views.py
--- organization/tests.py
+++ organization/tests.py
@@ -1,9 +1,9 @@
1 import pytest
2 from django.contrib.auth.models import User
3
4 from .models import Organization, OrganizationMember, Team
5
6
7 @pytest.mark.django_db
8 class TestOrganization:
9 def test_create_organization(self):
@@ -31,5 +31,149 @@
31 OrganizationMember.objects.create(member=admin_user, organization=org)
32
33 def test_str_representation(self, admin_user, org):
34 member = OrganizationMember.objects.get(member=admin_user, organization=org)
35 assert str(member) == f"{org}/{admin_user}"
36
37
38 @pytest.mark.django_db
39 class TestOrgSettingsViews:
40 def test_settings_page_renders(self, admin_client, org):
41 response = admin_client.get("/settings/")
42 assert response.status_code == 200
43 assert org.name in response.content.decode()
44
45 def test_settings_denied_without_perm(self, no_perm_client, org):
46 response = no_perm_client.get("/settings/")
47 assert response.status_code == 403
48
49 def test_settings_edit_renders(self, admin_client, org):
50 response = admin_client.get("/settings/edit/")
51 assert response.status_code == 200
52
53 def test_settings_edit_saves(self, admin_client, org):
54 response = admin_client.post("/settings/edit/", {"name": "Updated Org", "description": "New desc", "website": ""})
55 assert response.status_code == 302
56 org.refresh_from_db()
57 assert org.name == "Updated Org"
58
59 def test_settings_edit_denied(self, no_perm_client, org):
60 response = no_perm_client.post("/settings/edit/", {"name": "Hacked"})
61 assert response.status_code == 403
62
63
64 @pytest.mark.django_db
65 class TestMemberViews:
66 def test_member_list_renders(self, admin_client, org):
67 response = admin_client.get("/settings/members/")
68 assert response.status_code == 200
69
70 def test_member_list_htmx_returns_partial(self, admin_client, org):
71 response = admin_client.get("/settings/members/", HTTP_HX_REQUEST="true")
72 assert response.status_code == 200
73 assert b"member-table" in response.content
74
75 def test_member_list_search(self, admin_client, org):
76 response = admin_client.get("/settings/members/?search=admin")
77 assert response.status_code == 200
78
79 def test_member_list_denied(self, no_perm_client, org):
80 response = no_perm_client.get("/settings/members/")
81 assert response.status_code == 403
82
83 def test_member_add(self, admin_client, org):
84 User.objects.create_user(username="newuser", password="x")
85 response = admin_client.post("/settings/members/add/", {"user": User.objects.get(username="newuser").id})
86 assert response.status_code == 302
87 assert OrganizationMember.objects.filter(member__username="newuser", organization=org).exists()
88
89 def test_member_add_denied(self, no_perm_client, org):
90 response = no_perm_client.get("/settings/members/add/")
91 assert response.status_code == 403
92
93 def test_member_remove(self, admin_client, org, admin_user):
94 response = admin_client.post(f"/settings/members/{admin_user.username}/remove/")
95 assert response.status_code == 302
96 membership = OrganizationMember.all_objects.get(member=admin_user, organization=org)
97 assert membership.is_deleted
98
99 def test_member_remove_denied(self, no_perm_client, org, admin_user):
100 response = no_perm_client.post(f"/settings/members/{admin_user.username}/remove/")
101 assert response.status_code == 403
102
103
104 @pytest.mark.django_db
105 class TestTeamModel:
106 def test_create_team(self, org, admin_user):
107 team = Team.objects.create(name="Backend", organization=org, created_by=admin_user)
108 assert team.slug == "backend"
109 assert team.guid is not None
110
111 def test_soft_delete_team(self, sample_team, admin_user):
112 sample_team.soft_delete(user=admin_user)
113 assert Team.objects.filter(slug=sample_team.slug).count() == 0
114 assert Team.all_objects.filter(slug=sample_team.slug).count() == 1
115
116
117 @pytest.mark.django_db
118 class TestTeamViews:
119 def test_team_list_renders(self, admin_client, org, sample_team):
120 response = admin_client.get("/settings/teams/")
121 assert response.status_code == 200
122 assert sample_team.name in response.content.decode()
123
124 def test_team_list_htmx(self, admin_client, org, sample_team):
125 response = admin_client.get("/settings/teams/", HTTP_HX_REQUEST="true")
126 assert response.status_code == 200
127 assert b"team-table" in response.content
128
129 def test_team_list_search(self, admin_client, org, sample_team):
130 response = admin_client.get("/settings/teams/?search=Core")
131 assert response.status_code == 200
132
133 def test_team_list_denied(self, no_perm_client, org):
134 response = no_perm_client.get("/settings/teams/")
135 assert response.status_code == 403
136
137 def test_team_create(self, admin_client, org):
138 response = admin_client.post("/settings/teams/create/", {"name": "New Team", "description": "A new team"})
139 assert response.status_code == 302
140 assert Team.objects.filter(slug="new-team").exists()
141
142 def test_team_create_denied(self, no_perm_client, org):
143 response = no_perm_client.post("/settings/teams/create/", {"name": "Hack Team"})
144 assert response.status_code == 403
145
146 def test_team_detail_renders(self, admin_client, sample_team):
147 response = admin_client.get(f"/settings/teams/{sample_team.slug}/")
148 assert response.status_code == 200
149 assert sample_team.name in response.content.decode()
150
151 def test_team_update(self, admin_client, sample_team):
152 response = admin_client.post(f"/settings/teams/{sample_team.slug}/edit/", {"name": "Updated Team", "description": ""})
153 assert response.status_code == 302
154 sample_team.refresh_from_db()
155 assert sample_team.name == "Updated Team"
156
157 def test_team_update_denied(self, no_perm_client, sample_team):
158 response = no_perm_client.post(f"/settings/teams/{sample_team.slug}/edit/", {"name": "Hacked"})
159 assert response.status_code == 403
160
161 def test_team_delete(self, admin_client, sample_team):
162 response = admin_client.post(f"/settings/teams/{sample_team.slug}/delete/")
163 assert response.status_code == 302
164 assert Team.objects.filter(slug=sample_team.slug).count() == 0
165
166 def test_team_delete_denied(self, no_perm_client, sample_team):
167 response = no_perm_client.post(f"/settings/teams/{sample_team.slug}/delete/")
168 assert response.status_code == 403
169
170 def test_team_member_add(self, admin_client, sample_team):
171 new_user = User.objects.create_user(username="teamuser", password="x")
172 response = admin_client.post(f"/settings/teams/{sample_team.slug}/members/add/", {"user": new_user.id})
173 assert response.status_code == 302
174 assert sample_team.members.filter(username="teamuser").exists()
175
176 def test_team_member_remove(self, admin_client, sample_team, admin_user):
177 response = admin_client.post(f"/settings/teams/{sample_team.slug}/members/{admin_user.username}/remove/")
178 assert response.status_code == 302
179 assert not sample_team.members.filter(username=admin_user.username).exists()
180
181 DDED organization/urls.py
182 DDED organization/views.py
183 DDED pages/__init__.py
184 DDED pages/admin.py
185 DDED pages/apps.py
186 DDED pages/forms.py
187 DDED pages/migrations/0001_initial.py
188 DDED pages/migrations/__init__.py
189 DDED pages/models.py
190 DDED pages/tests.py
191 DDED pages/urls.py
192 DDED pages/views.py
193 DDED projects/__init__.py
194 DDED projects/admin.py
195 DDED projects/apps.py
196 DDED projects/forms.py
197 DDED projects/migrations/0001_initial.py
198 DDED projects/migrations/__init__.py
199 DDED projects/models.py
200 DDED projects/tests.py
201 DDED projects/urls.py
202 DDED projects/views.py
--- a/organization/urls.py
+++ b/organization/urls.py
@@ -0,0 +1,33 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "organization"
6
+
7
+urlpatterns = [
8
+ # Organization settings
9
+ path("", views.org_settings, name="settings"),
10
+ path("edit/", views.org_settings_edit, name="settings_edit"),
11
+ # Members
12
+ path("members/", views.member_list, name="members"),
13
+ path("members/add/", views.member_add, name="member_add"),
14
+ path("members/<str:username>/remove/", views.member_remove, naport path
15
+
16
+from . import views
17
+
18
+app_name = "organization"
19
+
20
+urlpatterns = [
21
+ # Organization settings
22
+ path("", views.org_settings, name="settings"),
23
+ path("edit/", views.org_settings_edit, name="settings_edit"),
24
+ # Members
25
+ path("members/", views.member_list, name="members"),
26
+ path("members/add/", views.member_add, name="member_add"),
27
+ path("members/create/", views.user_create, name="user_create"),
28
+ path("members/<str:username>/", views.user_detail, name="user_detail"),
29
+ path("members/<str:username>/edfrom django.urls import path
30
+
31
+from . import views
32
+
33
+app
--- a/organization/urls.py
+++ b/organization/urls.py
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/urls.py
+++ b/organization/urls.py
@@ -0,0 +1,33 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "organization"
6
7 urlpatterns = [
8 # Organization settings
9 path("", views.org_settings, name="settings"),
10 path("edit/", views.org_settings_edit, name="settings_edit"),
11 # Members
12 path("members/", views.member_list, name="members"),
13 path("members/add/", views.member_add, name="member_add"),
14 path("members/<str:username>/remove/", views.member_remove, naport path
15
16 from . import views
17
18 app_name = "organization"
19
20 urlpatterns = [
21 # Organization settings
22 path("", views.org_settings, name="settings"),
23 path("edit/", views.org_settings_edit, name="settings_edit"),
24 # Members
25 path("members/", views.member_list, name="members"),
26 path("members/add/", views.member_add, name="member_add"),
27 path("members/create/", views.user_create, name="user_create"),
28 path("members/<str:username>/", views.user_detail, name="user_detail"),
29 path("members/<str:username>/edfrom django.urls import path
30
31 from . import views
32
33 app
--- a/organization/views.py
+++ b/organization/views.py
@@ -0,0 +1,176 @@
1
+from django.contrib import messages
2
+from django.contrib.auth.decorators import login_required
3
+from django.contrib.auth.models import User
4
+from django.http import HttpResponse
5
+from django.shortcuts import get_object_or_404, reermissions imMemberAddForm, OrganizationSettingsForm, TeamForm,equest, slug):
6
+
7
+from .models import OrganizTeam
8
+
9
+
10
+def get_org():
11
+ return Organization.objects.first()
12
+
13
+
14
+# --- Organization Settings ---
15
+
16
+
17
+@login_required
18
+def org_settings(request):
19
+ P.ORGANIZATION_VIEW.check(request.user)
20
+ org = get_org()
21
+ return render(request, "organization/settings.html", {"org": org})
22
+
23
+
24
+@login_required
25
+def org_settings_edit(request):
26
+ P.ORGANIZATION_CHANGE.check(request.user)
27
+ org = get_org()
28
+
29
+ if request.method == "POST":
30
+ form = OrganizationSettingsForm(request.POST, instance=org)
31
+ if form.is_valid():
32
+ org = form.save(commit=False)
33
+ org.updated_by = request.user
34
+ org.save()
35
+ messages.success(request, "Organization settings updated.")
36
+ return redirect("organization:settings")
37
+ else:
38
+ form = OrganizationSettingsForm(instance=org)
39
+
40
+ return render(request, "organization/settings_form.html", {"form": form, "org": org})
41
+
42
+
43
+# --- Members ---
44
+
45
+
46
+@login_required
47
+def member_list(request):
48
+ P.ORGANIZATION_MEMBER_VIEW.check(request.user)
49
+ org = get_org()
50
+ members = OrganizationMember.objects.filter(organization=o)
51
+
52
+ search = request.GET.get("search", "").strip()
53
+ if search:
54
+ members = members.filter(member__username__icontains=search)
55
+
56
+ g).select_related("return render(requemember_table.html", {"mem{"members": members, "})
57
+
58
+
59
+@login_required
60
+def role_edit(request, slug):
61
+ P.ORGANIZATION_CHANGE.check(request.user)
62
+ role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
63
+
64
+ if request.method == "POST":
65
+ form = OrgRoleForm(request.POST, instance=role)
66
+ if form.is_valid():
67
+ role = form.save(commit=False)
68
+ role.updated_by = request.user
69
+ role.save()
70
+ role.permissions.set(form.cleaned_data["permissions"])
71
+ messages.success(request, f'Role "{role.name}" updated.')
72
+ return redirect("organization:role_detail", slug=role.slug)
73
+ else:
74
+ form = OrgRoleForm(instance=role)
75
+
76
+ return render(request, "organization/role_form.html", {"form": form, "role": role, "title": f"Edit {role.name}"})
77
+
78
+
79
+@login_required
80
+def role_delete(request, slug):
81
+ P.ORGANIZATION_CHANGE.check(request.user)
82
+ role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
83
+ active_members = OrganizationMember.objects.filter(role=role, deleted_at__isnull=True)
84
+
85
+ if request.method == "POST":
86
+ if active_members.exists():
87
+ messages.error(
88
+ request, f'Cannot delete role "{role.name}" -- it has {active_members.count()} active member(s). Reassign them first.'
89
+ )
90
+ return redirect("organization:role_detail", slug=role.slug)
91
+
92
+ role.soft_delete(user=request.user)
93
+ messages.success(request, f'Role "{role.name}" deleted.')
94
+
95
+ if request.headers.get("HX-Request"):
96
+ return HttpResponse(status=200, headers={"HX-Redirect": "/settings/roles/"})
97
+
98
+ return redirect("organization:role_list")
99
+
100
+ return render(
101
+ request,
102
+ "organization/role_confirm_delete.html",
103
+ {"role": role, "active_members": active_members},
104
+ )
105
+
106
+
107
+@login_required
108
+def audit_log(request):
109
+ """Unified audit log across all tracked models. Requires superuser or org admin."""
110
+ from core.pagination import manual_paginate
111
+
112
+ if not request.user.is_superuser:
113
+ P.ORGANIZATION_CHANGE.check(request.user)
114
+
115
+ from fossil.models import FossilRepository
116
+ from projects.models import Project
117
+
118
+ trackable_models = [
119
+ ("Project", Project),
120
+ ("Organization", Organization),
121
+ ("Team", Team),
122
+ ("FossilRepository", FossilRepository),
123
+ ]
124
+
125
+ entries = []
126
+ model_filter = request.GET.get("model", "").strip()
127
+
128
+ for label, model in trackable_models:
129
+ if model_filter and label.lower() != model_filter.lower():
130
+ continue
131
+ history_model = model.history.model
132
+ qs = history_model.objects.all().select_related("history_user").order_by("-history_date")[:500]
133
+ for h in qs:
134
+ entries.append(
135
+ {
136
+ "date": h.history_date,
137
+ "user": h.history_user,
138
+ "action": h.get_history_type_display(),
139
+ "model": label,
140
+ "object_repr": str(h),
141
+ "object_id": h.pk,
142
+ }
143
+ )
144
+
145
+ entries.sort(key=lambda x: x["date"], reverse=True)
146
+
147
+ per_page = get_per_page(request)
148
+ entries, pagination = manual_paginate(entries, request, per_page=per_page)
149
+
150
+ available_models = [label for label, _ in trackable_models]
151
+
152
+ return render(
153
+ request,
154
+ "organization/audit_log.html",
155
+ {
156
+ "entries": entries,
157
+ "model_filter": model_filter,
158
+ "available_models": available_models,
159
+ "pagination": pagination,
160
+ "per_page": per_page,
161
+ "per_page_options": PER_PAGE_OPTIONS,
162
+ },
163
+ )
164
+
165
+
166
+@login_required
167
+def role_initialize(request):
168
+ P.ORGANIZATION_CHANGE.check(request.user)
169
+
170
+ if request.method == "POST":
171
+ from django.core.management import call_command
172
+
173
+ call_command("seed_roles")
174
+ messages.success(request, "Roles initialized successfully.")
175
+
176
+ return redirect("organization:role_list")
--- a/organization/views.py
+++ b/organization/views.py
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/views.py
+++ b/organization/views.py
@@ -0,0 +1,176 @@
1 from django.contrib import messages
2 from django.contrib.auth.decorators import login_required
3 from django.contrib.auth.models import User
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, reermissions imMemberAddForm, OrganizationSettingsForm, TeamForm,equest, slug):
6
7 from .models import OrganizTeam
8
9
10 def get_org():
11 return Organization.objects.first()
12
13
14 # --- Organization Settings ---
15
16
17 @login_required
18 def org_settings(request):
19 P.ORGANIZATION_VIEW.check(request.user)
20 org = get_org()
21 return render(request, "organization/settings.html", {"org": org})
22
23
24 @login_required
25 def org_settings_edit(request):
26 P.ORGANIZATION_CHANGE.check(request.user)
27 org = get_org()
28
29 if request.method == "POST":
30 form = OrganizationSettingsForm(request.POST, instance=org)
31 if form.is_valid():
32 org = form.save(commit=False)
33 org.updated_by = request.user
34 org.save()
35 messages.success(request, "Organization settings updated.")
36 return redirect("organization:settings")
37 else:
38 form = OrganizationSettingsForm(instance=org)
39
40 return render(request, "organization/settings_form.html", {"form": form, "org": org})
41
42
43 # --- Members ---
44
45
46 @login_required
47 def member_list(request):
48 P.ORGANIZATION_MEMBER_VIEW.check(request.user)
49 org = get_org()
50 members = OrganizationMember.objects.filter(organization=o)
51
52 search = request.GET.get("search", "").strip()
53 if search:
54 members = members.filter(member__username__icontains=search)
55
56 g).select_related("return render(requemember_table.html", {"mem{"members": members, "})
57
58
59 @login_required
60 def role_edit(request, slug):
61 P.ORGANIZATION_CHANGE.check(request.user)
62 role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
63
64 if request.method == "POST":
65 form = OrgRoleForm(request.POST, instance=role)
66 if form.is_valid():
67 role = form.save(commit=False)
68 role.updated_by = request.user
69 role.save()
70 role.permissions.set(form.cleaned_data["permissions"])
71 messages.success(request, f'Role "{role.name}" updated.')
72 return redirect("organization:role_detail", slug=role.slug)
73 else:
74 form = OrgRoleForm(instance=role)
75
76 return render(request, "organization/role_form.html", {"form": form, "role": role, "title": f"Edit {role.name}"})
77
78
79 @login_required
80 def role_delete(request, slug):
81 P.ORGANIZATION_CHANGE.check(request.user)
82 role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
83 active_members = OrganizationMember.objects.filter(role=role, deleted_at__isnull=True)
84
85 if request.method == "POST":
86 if active_members.exists():
87 messages.error(
88 request, f'Cannot delete role "{role.name}" -- it has {active_members.count()} active member(s). Reassign them first.'
89 )
90 return redirect("organization:role_detail", slug=role.slug)
91
92 role.soft_delete(user=request.user)
93 messages.success(request, f'Role "{role.name}" deleted.')
94
95 if request.headers.get("HX-Request"):
96 return HttpResponse(status=200, headers={"HX-Redirect": "/settings/roles/"})
97
98 return redirect("organization:role_list")
99
100 return render(
101 request,
102 "organization/role_confirm_delete.html",
103 {"role": role, "active_members": active_members},
104 )
105
106
107 @login_required
108 def audit_log(request):
109 """Unified audit log across all tracked models. Requires superuser or org admin."""
110 from core.pagination import manual_paginate
111
112 if not request.user.is_superuser:
113 P.ORGANIZATION_CHANGE.check(request.user)
114
115 from fossil.models import FossilRepository
116 from projects.models import Project
117
118 trackable_models = [
119 ("Project", Project),
120 ("Organization", Organization),
121 ("Team", Team),
122 ("FossilRepository", FossilRepository),
123 ]
124
125 entries = []
126 model_filter = request.GET.get("model", "").strip()
127
128 for label, model in trackable_models:
129 if model_filter and label.lower() != model_filter.lower():
130 continue
131 history_model = model.history.model
132 qs = history_model.objects.all().select_related("history_user").order_by("-history_date")[:500]
133 for h in qs:
134 entries.append(
135 {
136 "date": h.history_date,
137 "user": h.history_user,
138 "action": h.get_history_type_display(),
139 "model": label,
140 "object_repr": str(h),
141 "object_id": h.pk,
142 }
143 )
144
145 entries.sort(key=lambda x: x["date"], reverse=True)
146
147 per_page = get_per_page(request)
148 entries, pagination = manual_paginate(entries, request, per_page=per_page)
149
150 available_models = [label for label, _ in trackable_models]
151
152 return render(
153 request,
154 "organization/audit_log.html",
155 {
156 "entries": entries,
157 "model_filter": model_filter,
158 "available_models": available_models,
159 "pagination": pagination,
160 "per_page": per_page,
161 "per_page_options": PER_PAGE_OPTIONS,
162 },
163 )
164
165
166 @login_required
167 def role_initialize(request):
168 P.ORGANIZATION_CHANGE.check(request.user)
169
170 if request.method == "POST":
171 from django.core.management import call_command
172
173 call_command("seed_roles")
174 messages.success(request, "Roles initialized successfully.")
175
176 return redirect("organization:role_list")

No diff available

--- a/pages/admin.py
+++ b/pages/admin.py
@@ -0,0 +1,11 @@
1
+from django.contrib import admin
2
+
3
+from core.admin import BaseCoreAdmin
4
+
5
+from .models import Page
6
+
7
+
8
+@admin.register(Page)
9
+class PageAdmin(BaseCoreAdmin):
10
+ list_display = ("name", "slug", "is_published", "created_at" "created_at", "created_by")
11
+ list_filter = ("is_publish
--- a/pages/admin.py
+++ b/pages/admin.py
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/admin.py
+++ b/pages/admin.py
@@ -0,0 +1,11 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import Page
6
7
8 @admin.register(Page)
9 class PageAdmin(BaseCoreAdmin):
10 list_display = ("name", "slug", "is_published", "created_at" "created_at", "created_by")
11 list_filter = ("is_publish
--- a/pages/apps.py
+++ b/pages/apps.py
@@ -0,0 +1,6 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class PagesConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "pages"
--- a/pages/apps.py
+++ b/pages/apps.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/pages/apps.py
+++ b/pages/apps.py
@@ -0,0 +1,6 @@
1 from django.apps import AppConfig
2
3
4 class PagesConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "pages"
--- a/pages/forms.py
+++ b/pages/forms.py
@@ -0,0 +1,16 @@
1
+from django import forms
2
+
3
+from .models import Page
4
+
5
+tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
6
+
7
+
8
+class PageForm(forms.ModelForm):
9
+ class Meta:
10
+ model = Page
11
+ fields = ["name", "content", "is_published"]
12
+ widgets = {
13
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "Page title"}),
14
+ "content": forms.Textarea(attrs={"class": tw + " font-mono", "rows": 20, "placeholder": "Write in Markdown..."}),
15
+ "is_published": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}),
16
+ }
--- a/pages/forms.py
+++ b/pages/forms.py
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/forms.py
+++ b/pages/forms.py
@@ -0,0 +1,16 @@
1 from django import forms
2
3 from .models import Page
4
5 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
6
7
8 class PageForm(forms.ModelForm):
9 class Meta:
10 model = Page
11 fields = ["name", "content", "is_published"]
12 widgets = {
13 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Page title"}),
14 "content": forms.Textarea(attrs={"class": tw + " font-mono", "rows": 20, "placeholder": "Write in Markdown..."}),
15 "is_published": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}),
16 }
--- a/pages/migrations/0001_initial.py
+++ b/pages/migrations/0001_initial.py
@@ -0,0 +1,180 @@
1
+# Generated by Django 5.2.12 on 2026-04-06 01:25
2
+
3
+import uuid
4
+
5
+import django.db.models.deletion
6
+import simple_history.models
7
+from django.conf import settings
8
+from django.db import migrations, models
9
+
10
+
11
+class Migration(migrations.Migration):
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ("organization", "0002_historicalteam_team"),
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name="HistoricalPage",
22
+ fields=[
23
+ (
24
+ "id",
25
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26
+ ),
27
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
28
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
29
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
30
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
31
+ (
32
+ "guid",
33
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34
+ ),
35
+ ("name", models.CharField(max_length=200)),
36
+ ("slug", models.SlugField(max_length=200)),
37
+ ("description", models.TextField(blank=True, default="")),
38
+ ("content", models.TextField(blank=True, default="")),
39
+ ("is_published", models.BooleanField(default=True)),
40
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
41
+ ("history_date", models.DateTimeField(db_index=True)),
42
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
43
+ (
44
+ "history_type",
45
+ models.CharField(
46
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
47
+ max_length=1,
48
+ ),
49
+ ),
50
+ (
51
+ "created_by",
52
+ models.ForeignKey(
53
+ blank=True,
54
+ db_constraint=False,
55
+ null=True,
56
+ on_delete=django.db.models.deletion.DO_NOTHING,
57
+ related_name="+",
58
+ to=settings.AUTH_USER_MODEL,
59
+ ),
60
+ ),
61
+ (
62
+ "deleted_by",
63
+ models.ForeignKey(
64
+ blank=True,
65
+ db_constraint=False,
66
+ null=True,
67
+ on_delete=django.db.models.deletion.DO_NOTHING,
68
+ related_name="+",
69
+ to=settings.AUTH_USER_MODEL,
70
+ ),
71
+ ),
72
+ (
73
+ "history_user",
74
+ models.ForeignKey(
75
+ null=True,
76
+ on_delete=django.db.models.deletion.SET_NULL,
77
+ related_name="+",
78
+ to=settings.AUTH_USER_MODEL,
79
+ ),
80
+ ),
81
+ (
82
+ "organization",
83
+ models.ForeignKey(
84
+ blank=True,
85
+ db_constraint=False,
86
+ null=True,
87
+ on_delete=django.db.models.deletion.DO_NOTHING,
88
+ related_name="+",
89
+ to="organization.organization",
90
+ ),
91
+ ),
92
+ (
93
+ "updated_by",
94
+ models.ForeignKey(
95
+ blank=True,
96
+ db_constraint=False,
97
+ null=True,
98
+ on_delete=django.db.models.deletion.DO_NOTHING,
99
+ related_name="+",
100
+ to=settings.AUTH_USER_MODEL,
101
+ ),
102
+ ),
103
+ ],
104
+ options={
105
+ "verbose_name": "historical page",
106
+ "verbose_name_plural": "historical pages",
107
+ "ordering": ("-history_date", "-history_id"),
108
+ "get_latest_by": ("history_date", "history_id"),
109
+ },
110
+ bases=(simple_history.models.HistoricalChanges, models.Model),
111
+ ),
112
+ migrations.CreateModel(
113
+ name="Page",
114
+ fields=[
115
+ (
116
+ "id",
117
+ models.BigAutoField(
118
+ auto_created=True,
119
+ primary_key=True,
120
+ serialize=False,
121
+ verbose_name="ID",
122
+ ),
123
+ ),
124
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
125
+ ("created_at", models.DateTimeField(auto_now_add=True)),
126
+ ("updated_at", models.DateTimeField(auto_now=True)),
127
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
128
+ (
129
+ "guid",
130
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
131
+ ),
132
+ ("name", models.CharField(max_length=200)),
133
+ ("slug", models.SlugField(max_length=200, unique=True)),
134
+ ("description", models.TextField(blank=True, default="")),
135
+ ("content", models.TextField(blank=True, default="")),
136
+ ("is_published", models.BooleanField(default=True)),
137
+ (
138
+ "created_by",
139
+ models.ForeignKey(
140
+ blank=True,
141
+ null=True,
142
+ on_delete=django.db.models.deletion.SET_NULL,
143
+ related_name="+",
144
+ to=settings.AUTH_USER_MODEL,
145
+ ),
146
+ ),
147
+ (
148
+ "deleted_by",
149
+ models.ForeignKey(
150
+ blank=True,
151
+ null=True,
152
+ on_delete=django.db.models.deletion.SET_NULL,
153
+ related_name="+",
154
+ to=settings.AUTH_USER_MODEL,
155
+ ),
156
+ ),
157
+ (
158
+ "organization",
159
+ models.ForeignKey(
160
+ on_delete=django.db.models.deletion.CASCADE,
161
+ related_name="pages",
162
+ to="organization.organization",
163
+ ),
164
+ ),
165
+ (
166
+ "updated_by",
167
+ models.ForeignKey(
168
+ blank=True,
169
+ null=True,
170
+ on_delete=django.db.models.deletion.SET_NULL,
171
+ related_name="+",
172
+ to=settings.AUTH_USER_MODEL,
173
+ ),
174
+ ),
175
+ ],
176
+ options={
177
+ "ordering": ["name"],
178
+ },
179
+ ),
180
+ ]
--- a/pages/migrations/0001_initial.py
+++ b/pages/migrations/0001_initial.py
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/migrations/0001_initial.py
+++ b/pages/migrations/0001_initial.py
@@ -0,0 +1,180 @@
1 # Generated by Django 5.2.12 on 2026-04-06 01:25
2
3 import uuid
4
5 import django.db.models.deletion
6 import simple_history.models
7 from django.conf import settings
8 from django.db import migrations, models
9
10
11 class Migration(migrations.Migration):
12 initial = True
13
14 dependencies = [
15 ("organization", "0002_historicalteam_team"),
16 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17 ]
18
19 operations = [
20 migrations.CreateModel(
21 name="HistoricalPage",
22 fields=[
23 (
24 "id",
25 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26 ),
27 ("version", models.PositiveIntegerField(default=1, editable=False)),
28 ("created_at", models.DateTimeField(blank=True, editable=False)),
29 ("updated_at", models.DateTimeField(blank=True, editable=False)),
30 ("deleted_at", models.DateTimeField(blank=True, null=True)),
31 (
32 "guid",
33 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34 ),
35 ("name", models.CharField(max_length=200)),
36 ("slug", models.SlugField(max_length=200)),
37 ("description", models.TextField(blank=True, default="")),
38 ("content", models.TextField(blank=True, default="")),
39 ("is_published", models.BooleanField(default=True)),
40 ("history_id", models.AutoField(primary_key=True, serialize=False)),
41 ("history_date", models.DateTimeField(db_index=True)),
42 ("history_change_reason", models.CharField(max_length=100, null=True)),
43 (
44 "history_type",
45 models.CharField(
46 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
47 max_length=1,
48 ),
49 ),
50 (
51 "created_by",
52 models.ForeignKey(
53 blank=True,
54 db_constraint=False,
55 null=True,
56 on_delete=django.db.models.deletion.DO_NOTHING,
57 related_name="+",
58 to=settings.AUTH_USER_MODEL,
59 ),
60 ),
61 (
62 "deleted_by",
63 models.ForeignKey(
64 blank=True,
65 db_constraint=False,
66 null=True,
67 on_delete=django.db.models.deletion.DO_NOTHING,
68 related_name="+",
69 to=settings.AUTH_USER_MODEL,
70 ),
71 ),
72 (
73 "history_user",
74 models.ForeignKey(
75 null=True,
76 on_delete=django.db.models.deletion.SET_NULL,
77 related_name="+",
78 to=settings.AUTH_USER_MODEL,
79 ),
80 ),
81 (
82 "organization",
83 models.ForeignKey(
84 blank=True,
85 db_constraint=False,
86 null=True,
87 on_delete=django.db.models.deletion.DO_NOTHING,
88 related_name="+",
89 to="organization.organization",
90 ),
91 ),
92 (
93 "updated_by",
94 models.ForeignKey(
95 blank=True,
96 db_constraint=False,
97 null=True,
98 on_delete=django.db.models.deletion.DO_NOTHING,
99 related_name="+",
100 to=settings.AUTH_USER_MODEL,
101 ),
102 ),
103 ],
104 options={
105 "verbose_name": "historical page",
106 "verbose_name_plural": "historical pages",
107 "ordering": ("-history_date", "-history_id"),
108 "get_latest_by": ("history_date", "history_id"),
109 },
110 bases=(simple_history.models.HistoricalChanges, models.Model),
111 ),
112 migrations.CreateModel(
113 name="Page",
114 fields=[
115 (
116 "id",
117 models.BigAutoField(
118 auto_created=True,
119 primary_key=True,
120 serialize=False,
121 verbose_name="ID",
122 ),
123 ),
124 ("version", models.PositiveIntegerField(default=1, editable=False)),
125 ("created_at", models.DateTimeField(auto_now_add=True)),
126 ("updated_at", models.DateTimeField(auto_now=True)),
127 ("deleted_at", models.DateTimeField(blank=True, null=True)),
128 (
129 "guid",
130 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
131 ),
132 ("name", models.CharField(max_length=200)),
133 ("slug", models.SlugField(max_length=200, unique=True)),
134 ("description", models.TextField(blank=True, default="")),
135 ("content", models.TextField(blank=True, default="")),
136 ("is_published", models.BooleanField(default=True)),
137 (
138 "created_by",
139 models.ForeignKey(
140 blank=True,
141 null=True,
142 on_delete=django.db.models.deletion.SET_NULL,
143 related_name="+",
144 to=settings.AUTH_USER_MODEL,
145 ),
146 ),
147 (
148 "deleted_by",
149 models.ForeignKey(
150 blank=True,
151 null=True,
152 on_delete=django.db.models.deletion.SET_NULL,
153 related_name="+",
154 to=settings.AUTH_USER_MODEL,
155 ),
156 ),
157 (
158 "organization",
159 models.ForeignKey(
160 on_delete=django.db.models.deletion.CASCADE,
161 related_name="pages",
162 to="organization.organization",
163 ),
164 ),
165 (
166 "updated_by",
167 models.ForeignKey(
168 blank=True,
169 null=True,
170 on_delete=django.db.models.deletion.SET_NULL,
171 related_name="+",
172 to=settings.AUTH_USER_MODEL,
173 ),
174 ),
175 ],
176 options={
177 "ordering": ["name"],
178 },
179 ),
180 ]

No diff available

--- a/pages/models.py
+++ b/pages/models.py
@@ -0,0 +1,15 @@
1
+from django.db import models
2
+
3
+from core.models import ActiveManager, BaseCoreModel
4
+
5
+
6
+class Page(BaseCoreModel):
7
+ content = models.TextField(blank=True, default="")
8
+ is_published = models.BooleanField(default=True)
9
+ organization = models.ForeignKey("organization.Organization", on_delete=models.CASCADE, related_name="pages")
10
+
11
+ objects = ActiveManager()
12
+ all_objects = models.Manager()
13
+
14
+ class Meta:
15
+ ordering = ["name"]
--- a/pages/models.py
+++ b/pages/models.py
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/models.py
+++ b/pages/models.py
@@ -0,0 +1,15 @@
1 from django.db import models
2
3 from core.models import ActiveManager, BaseCoreModel
4
5
6 class Page(BaseCoreModel):
7 content = models.TextField(blank=True, default="")
8 is_published = models.BooleanField(default=True)
9 organization = models.ForeignKey("organization.Organization", on_delete=models.CASCADE, related_name="pages")
10
11 objects = ActiveManager()
12 all_objects = models.Manager()
13
14 class Meta:
15 ordering = ["name"]
--- a/pages/tests.py
+++ b/pages/tests.py
@@ -0,0 +1,56 @@
1
+import pytest
2
+
3
+from .models import Page
4
+
5
+
6
+@pytest.mark.django_db
7
+class TestPageModel:
8
+ def test_create_page(self, org, admin_user):
9
+ page = Page.objects.create(name="Test Page", content="# Hello", organization=org, created_by=admin_user)
10
+ assert page.slug == "test-page"
11
+ assert page.guid is not None
12
+ assert page.is_published is True
13
+
14
+ def test_soft_delete_page(self, sample_page, admin_user):
15
+ sample_page.soft_delete(user=admin_user)
16
+ assert Page.objects.filter(slug=sample_page.slug).count() == 0
17
+ assert Page.all_objects.filter(slug=sample_page.slug).count() == 1
18
+
19
+
20
+@pytest.mark.django_db
21
+class TestPageViews:
22
+ def test_page_list_renders(self, admin_client, sample_page):
23
+ response = admin_client.get("/docs200
24
+ assert sample_page.name in response.content.decode()
25
+
26
+ def test_page_list_htmx(self, admin_client, sample_page):
27
+ response = admin_client.get("/docs/", HTTP_HX_REQUEST="true")
28
+ assert response.status_code == 200
29
+ assert b"page-table" in response.content
30
+
31
+ def test_page_list_search(self, admin_client, sample_page):
32
+ response = admin_client.get("/docsnse = admin_client.get("/kb/?search=Getting")
33
+ assert response.status_code == 200
34
+
35
+ def test_page_list_(self, admin_client, sample):
36
+ respodocscreate(self, admin_client, org):
37
+ response = admin_client.post("/docs/create/", {"name": "t("/kb/create/", {"name": "New Page", "content": "# New", "is_published": True})
38
+ assert response.status_code == 302
39
+ assert Page.objects.filter(slug="new-page").exists()
40
+
41
+ def test_page_create_denied(self, no_perm_client, org):
42
+ respondocs/create/", {"name": "Hack"})
43
+ assert response.status_code == 403
44
+
45
+ def test_page_detail_renders_markdown(self, admin_client, sample_page):
46
+ respdocs response.content
47
+
48
+ ")
49
+ assert ew", "is_published": Trurt response.status_code == 200
50
+ content = response.content.decode()
51
+ assert "<h1>" in content or "Getting Started" in content(self, admin_client, sample_page):
52
+ resdocs response.content
53
+
54
+ ")
55
+ assert om .models import Paimpate(self, admin_client, org):
56
+ response = admin_client.post("/kb/create/", {"n
--- a/pages/tests.py
+++ b/pages/tests.py
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/tests.py
+++ b/pages/tests.py
@@ -0,0 +1,56 @@
1 import pytest
2
3 from .models import Page
4
5
6 @pytest.mark.django_db
7 class TestPageModel:
8 def test_create_page(self, org, admin_user):
9 page = Page.objects.create(name="Test Page", content="# Hello", organization=org, created_by=admin_user)
10 assert page.slug == "test-page"
11 assert page.guid is not None
12 assert page.is_published is True
13
14 def test_soft_delete_page(self, sample_page, admin_user):
15 sample_page.soft_delete(user=admin_user)
16 assert Page.objects.filter(slug=sample_page.slug).count() == 0
17 assert Page.all_objects.filter(slug=sample_page.slug).count() == 1
18
19
20 @pytest.mark.django_db
21 class TestPageViews:
22 def test_page_list_renders(self, admin_client, sample_page):
23 response = admin_client.get("/docs200
24 assert sample_page.name in response.content.decode()
25
26 def test_page_list_htmx(self, admin_client, sample_page):
27 response = admin_client.get("/docs/", HTTP_HX_REQUEST="true")
28 assert response.status_code == 200
29 assert b"page-table" in response.content
30
31 def test_page_list_search(self, admin_client, sample_page):
32 response = admin_client.get("/docsnse = admin_client.get("/kb/?search=Getting")
33 assert response.status_code == 200
34
35 def test_page_list_(self, admin_client, sample):
36 respodocscreate(self, admin_client, org):
37 response = admin_client.post("/docs/create/", {"name": "t("/kb/create/", {"name": "New Page", "content": "# New", "is_published": True})
38 assert response.status_code == 302
39 assert Page.objects.filter(slug="new-page").exists()
40
41 def test_page_create_denied(self, no_perm_client, org):
42 respondocs/create/", {"name": "Hack"})
43 assert response.status_code == 403
44
45 def test_page_detail_renders_markdown(self, admin_client, sample_page):
46 respdocs response.content
47
48 ")
49 assert ew", "is_published": Trurt response.status_code == 200
50 content = response.content.decode()
51 assert "<h1>" in content or "Getting Started" in content(self, admin_client, sample_page):
52 resdocs response.content
53
54 ")
55 assert om .models import Paimpate(self, admin_client, org):
56 response = admin_client.post("/kb/create/", {"n
--- a/pages/urls.py
+++ b/pages/urls.py
@@ -0,0 +1,13 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "pages"
6
+
7
+urlpatterns = [
8
+ path("", views.page_list, name="list"),
9
+ path("create/", views.page_create, name="create"),
10
+ path("<slug:slug>/", views.page_detail, name="detail"),
11
+ path("<slug:slug>/edit/", views.page_update, name="update"),
12
+ path("<slug:slug>/delete/", views.page_delete, name="delete"),
13
+]
--- a/pages/urls.py
+++ b/pages/urls.py
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/urls.py
+++ b/pages/urls.py
@@ -0,0 +1,13 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "pages"
6
7 urlpatterns = [
8 path("", views.page_list, name="list"),
9 path("create/", views.page_create, name="create"),
10 path("<slug:slug>/", views.page_detail, name="detail"),
11 path("<slug:slug>/edit/", views.page_update, name="update"),
12 path("<slug:slug>/delete/", views.page_delete, name="delete"),
13 ]
--- a/pages/views.py
+++ b/pages/views.py
@@ -0,0 +1,43 @@
1
+import markdown
2
+from django.contrib import messages
3
+from django.contrib.auth.decorators import port Paginator
4
+from django.http import HttpResponse
5
+from django.shortcuts import get_object_or_404, redirect, render
6
+from django.utils.safestring imermissions import Pnitize import sanitize_html
7
+from organization.views import get_org
8
+
9
+from .forms import PageForm
10
+ge)
11
+ page_obj = paginalist(request):
12
+ P.PAGE_VIEW
13
+
14
+ ctx = {"pages": page_obwn
15
+from django.contriimpenticated and (request.user.has_perm("pages.change_page") or request.us:
16
+ pages = Page.objects.all( from django.core.eUnpublished drafts require auth n render(request, "pages/{"pages": pages})
17
+
18
+ return rlist.html", {"pageer_page)
19
+ page_obj = paginator.get_page(request.GET.get("page", 1))
20
+
21
+ ctx = {"pages": page_obj, "page_obj": page_obj, "search": search, "per_page": per_page, "per_page_options": PER_PAGE_OPTIONS}
22
+
23
+ if request.headers.get("HX-Request"):
24
+ return render(request, "pages/partials/page_table.html", ctx)
25
+
26
+ return render(request, "pages/page_list.html", ctx)
27
+
28
+
29
+@login_required
30
+def page_create(request):
31
+ P.PAGE_ADD.check(request.user)
32
+ org = get_org()
33
+
34
+ if request.method == "POST":
35
+ form = PageForm(request.POST)
36
+ if form.is_valid():
37
+ pagedocs/"})
38
+
39
+": pages})
40
+
41
+ return rlist.ht(commit=False)
42
+ page.organization = org
43
+ page.created_by = requ
--- a/pages/views.py
+++ b/pages/views.py
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/views.py
+++ b/pages/views.py
@@ -0,0 +1,43 @@
1 import markdown
2 from django.contrib import messages
3 from django.contrib.auth.decorators import port Paginator
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, redirect, render
6 from django.utils.safestring imermissions import Pnitize import sanitize_html
7 from organization.views import get_org
8
9 from .forms import PageForm
10 ge)
11 page_obj = paginalist(request):
12 P.PAGE_VIEW
13
14 ctx = {"pages": page_obwn
15 from django.contriimpenticated and (request.user.has_perm("pages.change_page") or request.us:
16 pages = Page.objects.all( from django.core.eUnpublished drafts require auth n render(request, "pages/{"pages": pages})
17
18 return rlist.html", {"pageer_page)
19 page_obj = paginator.get_page(request.GET.get("page", 1))
20
21 ctx = {"pages": page_obj, "page_obj": page_obj, "search": search, "per_page": per_page, "per_page_options": PER_PAGE_OPTIONS}
22
23 if request.headers.get("HX-Request"):
24 return render(request, "pages/partials/page_table.html", ctx)
25
26 return render(request, "pages/page_list.html", ctx)
27
28
29 @login_required
30 def page_create(request):
31 P.PAGE_ADD.check(request.user)
32 org = get_org()
33
34 if request.method == "POST":
35 form = PageForm(request.POST)
36 if form.is_valid():
37 pagedocs/"})
38
39 ": pages})
40
41 return rlist.ht(commit=False)
42 page.organization = org
43 page.created_by = requ

No diff available

--- a/projects/admin.py
+++ b/projects/admin.py
@@ -0,0 +1,29 @@
1
+from django.contrib import admin
2
+
3
+from core.admin import BaseCoreAdmin
4
+
5
+from .modTeam
6
+
7
+
8
+class ProjectTeam"slug")
9
+
10
+
11
+class ProjectTeamInline(admin.TabularInline):
12
+ model = ProjectTeam
13
+ extra = 0
14
+ raw_id_fields = ("team",)
15
+
16
+
17
+@admin.register(Project)
18
+class ProjectAdmin(BaseCoreAdmin):
19
+ list_dvisibility", "organization", "created_at")
20
+ ted_at", "created_by")
21
+ lity", "gro)
22
+ inlines = [ProjectTeamInline]
23
+
24
+
25
+@admin.register(ProjectTeam)
26
+class ProjectTeamAdmin(BaseCoreAdmin):
27
+ list_display = ("project", "team", "role", "created_at")
28
+ list_filter = ("role",)
29
+ raw_id_fields = ("project", "team")
--- a/projects/admin.py
+++ b/projects/admin.py
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/admin.py
+++ b/projects/admin.py
@@ -0,0 +1,29 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .modTeam
6
7
8 class ProjectTeam"slug")
9
10
11 class ProjectTeamInline(admin.TabularInline):
12 model = ProjectTeam
13 extra = 0
14 raw_id_fields = ("team",)
15
16
17 @admin.register(Project)
18 class ProjectAdmin(BaseCoreAdmin):
19 list_dvisibility", "organization", "created_at")
20 ted_at", "created_by")
21 lity", "gro)
22 inlines = [ProjectTeamInline]
23
24
25 @admin.register(ProjectTeam)
26 class ProjectTeamAdmin(BaseCoreAdmin):
27 list_display = ("project", "team", "role", "created_at")
28 list_filter = ("role",)
29 raw_id_fields = ("project", "team")
--- a/projects/apps.py
+++ b/projects/apps.py
@@ -0,0 +1,6 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class ProjectsConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "projects"
--- a/projects/apps.py
+++ b/projects/apps.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/projects/apps.py
+++ b/projects/apps.py
@@ -0,0 +1,6 @@
1 from django.apps import AppConfig
2
3
4 class ProjectsConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "projects"
--- a/projects/forms.py
+++ b/projects/forms.py
@@ -0,0 +1,35 @@
1
+ject, ProjectGroup, ProjectTeam
2
+
3
+tw = "w-full rounded-md border-gray-300 shado (optional)"})class Meta:
4
+ model = Project
5
+ fields = ["name", "description", "visibility"]
6
+ widgets = {
7
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "}L.")
8
+ return cleaned
9
+
10
+
11
+class ProjectTeamAddForm(forms.Form):
12
+ team = forms.ModelChoiceField(
13
+ queryset=Team.objects.none(),
14
+ widget=forms.Select(attrs={"class": tw}),
15
+ label="Team",
16
+ )
17
+ role = forms.ChoiceField(
18
+ choices=ProjectTeam.Role.choices,
19
+ widget=forms.Select(attrs={"class": tw}),
20
+ label="Role",
21
+ )
22
+
23
+ def __init__(self, *args, project=None, **kwargs):
24
+ super().__init__(*args, **kwargs)
25
+ if project:
26
+ assigned_team_ids = project.project_teams.filter(deleted_at__isnull=True).values_list("team_id", flat=True)
27
+ self.fields["team"].queryset = Team.objects.filter(organization=project.organization, deleted_at__isnull=True).exclude(
28
+ id__in=assigned_team_ids
29
+ )
30
+
31
+
32
+class ProjectTeamEditForm(forms.Form):
33
+ role = forms.ChoiceField(
34
+ choices=ProjectTeam.Role.choices,
35
+ widget=forms.Sel
--- a/projects/forms.py
+++ b/projects/forms.py
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/forms.py
+++ b/projects/forms.py
@@ -0,0 +1,35 @@
1 ject, ProjectGroup, ProjectTeam
2
3 tw = "w-full rounded-md border-gray-300 shado (optional)"})class Meta:
4 model = Project
5 fields = ["name", "description", "visibility"]
6 widgets = {
7 "name": forms.TextInput(attrs={"class": tw, "placeholder": "}L.")
8 return cleaned
9
10
11 class ProjectTeamAddForm(forms.Form):
12 team = forms.ModelChoiceField(
13 queryset=Team.objects.none(),
14 widget=forms.Select(attrs={"class": tw}),
15 label="Team",
16 )
17 role = forms.ChoiceField(
18 choices=ProjectTeam.Role.choices,
19 widget=forms.Select(attrs={"class": tw}),
20 label="Role",
21 )
22
23 def __init__(self, *args, project=None, **kwargs):
24 super().__init__(*args, **kwargs)
25 if project:
26 assigned_team_ids = project.project_teams.filter(deleted_at__isnull=True).values_list("team_id", flat=True)
27 self.fields["team"].queryset = Team.objects.filter(organization=project.organization, deleted_at__isnull=True).exclude(
28 id__in=assigned_team_ids
29 )
30
31
32 class ProjectTeamEditForm(forms.Form):
33 role = forms.ChoiceField(
34 choices=ProjectTeam.Role.choices,
35 widget=forms.Sel
--- a/projects/migrations/0001_initial.py
+++ b/projects/migrations/0001_initial.py
@@ -0,0 +1,395 @@
1
+# Generated by Django 5.2.12 on 2026-04-06 01:11
2
+
3
+import uuid
4
+
5
+import django.db.models.deletion
6
+import simple_history.models
7
+from django.conf import settings
8
+from django.db import migrations, models
9
+
10
+
11
+class Migration(migrations.Migration):
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ("organization", "0002_historicalteam_team"),
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name="HistoricalProject",
22
+ fields=[
23
+ (
24
+ "id",
25
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26
+ ),
27
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
28
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
29
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
30
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
31
+ (
32
+ "guid",
33
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34
+ ),
35
+ ("name", models.CharField(max_length=200)),
36
+ ("slug", models.SlugField(max_length=200)),
37
+ ("description", models.TextField(blank=True, default="")),
38
+ (
39
+ "visibility",
40
+ models.CharField(
41
+ choices=[
42
+ ("public", "Public"),
43
+ ("internal", "Internal"),
44
+ ("private", "Private"),
45
+ ],
46
+ default="private",
47
+ max_length=10,
48
+ ),
49
+ ),
50
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
51
+ ("history_date", models.DateTimeField(db_index=True)),
52
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
53
+ (
54
+ "history_type",
55
+ models.CharField(
56
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
57
+ max_length=1,
58
+ ),
59
+ ),
60
+ (
61
+ "created_by",
62
+ models.ForeignKey(
63
+ blank=True,
64
+ db_constraint=False,
65
+ null=True,
66
+ on_delete=django.db.models.deletion.DO_NOTHING,
67
+ related_name="+",
68
+ to=settings.AUTH_USER_MODEL,
69
+ ),
70
+ ),
71
+ (
72
+ "deleted_by",
73
+ models.ForeignKey(
74
+ blank=True,
75
+ db_constraint=False,
76
+ null=True,
77
+ on_delete=django.db.models.deletion.DO_NOTHING,
78
+ related_name="+",
79
+ to=settings.AUTH_USER_MODEL,
80
+ ),
81
+ ),
82
+ (
83
+ "history_user",
84
+ models.ForeignKey(
85
+ null=True,
86
+ on_delete=django.db.models.deletion.SET_NULL,
87
+ related_name="+",
88
+ to=settings.AUTH_USER_MODEL,
89
+ ),
90
+ ),
91
+ (
92
+ "organization",
93
+ models.ForeignKey(
94
+ blank=True,
95
+ db_constraint=False,
96
+ null=True,
97
+ on_delete=django.db.models.deletion.DO_NOTHING,
98
+ related_name="+",
99
+ to="organization.organization",
100
+ ),
101
+ ),
102
+ (
103
+ "updated_by",
104
+ models.ForeignKey(
105
+ blank=True,
106
+ db_constraint=False,
107
+ null=True,
108
+ on_delete=django.db.models.deletion.DO_NOTHING,
109
+ related_name="+",
110
+ to=settings.AUTH_USER_MODEL,
111
+ ),
112
+ ),
113
+ ],
114
+ options={
115
+ "verbose_name": "historical project",
116
+ "verbose_name_plural": "historical projects",
117
+ "ordering": ("-history_date", "-history_id"),
118
+ "get_latest_by": ("history_date", "history_id"),
119
+ },
120
+ bases=(simple_history.models.HistoricalChanges, models.Model),
121
+ ),
122
+ migrations.CreateModel(
123
+ name="Project",
124
+ fields=[
125
+ (
126
+ "id",
127
+ models.BigAutoField(
128
+ auto_created=True,
129
+ primary_key=True,
130
+ serialize=False,
131
+ verbose_name="ID",
132
+ ),
133
+ ),
134
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
135
+ ("created_at", models.DateTimeField(auto_now_add=True)),
136
+ ("updated_at", models.DateTimeField(auto_now=True)),
137
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
138
+ (
139
+ "guid",
140
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
141
+ ),
142
+ ("name", models.CharField(max_length=200)),
143
+ ("slug", models.SlugField(max_length=200, unique=True)),
144
+ ("description", models.TextField(blank=True, default="")),
145
+ (
146
+ "visibility",
147
+ models.CharField(
148
+ choices=[
149
+ ("public", "Public"),
150
+ ("internal", "Internal"),
151
+ ("private", "Private"),
152
+ ],
153
+ default="private",
154
+ max_length=10,
155
+ ),
156
+ ),
157
+ (
158
+ "created_by",
159
+ models.ForeignKey(
160
+ blank=True,
161
+ null=True,
162
+ on_delete=django.db.models.deletion.SET_NULL,
163
+ related_name="+",
164
+ to=settings.AUTH_USER_MODEL,
165
+ ),
166
+ ),
167
+ (
168
+ "deleted_by",
169
+ models.ForeignKey(
170
+ blank=True,
171
+ null=True,
172
+ on_delete=django.db.models.deletion.SET_NULL,
173
+ related_name="+",
174
+ to=settings.AUTH_USER_MODEL,
175
+ ),
176
+ ),
177
+ (
178
+ "organization",
179
+ models.ForeignKey(
180
+ on_delete=django.db.models.deletion.CASCADE,
181
+ related_name="projects",
182
+ to="organization.organization",
183
+ ),
184
+ ),
185
+ (
186
+ "updated_by",
187
+ models.ForeignKey(
188
+ blank=True,
189
+ null=True,
190
+ on_delete=django.db.models.deletion.SET_NULL,
191
+ related_name="+",
192
+ to=settings.AUTH_USER_MODEL,
193
+ ),
194
+ ),
195
+ ],
196
+ options={
197
+ "ordering": ["name"],
198
+ },
199
+ ),
200
+ migrations.CreateModel(
201
+ name="HistoricalProjectTeam",
202
+ fields=[
203
+ (
204
+ "id",
205
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
206
+ ),
207
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
208
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
209
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
210
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
211
+ (
212
+ "role",
213
+ models.CharField(
214
+ choices=[
215
+ ("read", "Read"),
216
+ ("write", "Write"),
217
+ ("admin", "Admin"),
218
+ ],
219
+ default="read",
220
+ max_length=10,
221
+ ),
222
+ ),
223
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
224
+ ("history_date", models.DateTimeField(db_index=True)),
225
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
226
+ (
227
+ "history_type",
228
+ models.CharField(
229
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
230
+ max_length=1,
231
+ ),
232
+ ),
233
+ (
234
+ "created_by",
235
+ models.ForeignKey(
236
+ blank=True,
237
+ db_constraint=False,
238
+ null=True,
239
+ on_delete=django.db.models.deletion.DO_NOTHING,
240
+ related_name="+",
241
+ to=settings.AUTH_USER_MODEL,
242
+ ),
243
+ ),
244
+ (
245
+ "deleted_by",
246
+ models.ForeignKey(
247
+ blank=True,
248
+ db_constraint=False,
249
+ null=True,
250
+ on_delete=django.db.models.deletion.DO_NOTHING,
251
+ related_name="+",
252
+ to=settings.AUTH_USER_MODEL,
253
+ ),
254
+ ),
255
+ (
256
+ "history_user",
257
+ models.ForeignKey(
258
+ null=True,
259
+ on_delete=django.db.models.deletion.SET_NULL,
260
+ related_name="+",
261
+ to=settings.AUTH_USER_MODEL,
262
+ ),
263
+ ),
264
+ (
265
+ "team",
266
+ models.ForeignKey(
267
+ blank=True,
268
+ db_constraint=False,
269
+ null=True,
270
+ on_delete=django.db.models.deletion.DO_NOTHING,
271
+ related_name="+",
272
+ to="organization.team",
273
+ ),
274
+ ),
275
+ (
276
+ "updated_by",
277
+ models.ForeignKey(
278
+ blank=True,
279
+ db_constraint=False,
280
+ null=True,
281
+ on_delete=django.db.models.deletion.DO_NOTHING,
282
+ related_name="+",
283
+ to=settings.AUTH_USER_MODEL,
284
+ ),
285
+ ),
286
+ (
287
+ "project",
288
+ models.ForeignKey(
289
+ blank=True,
290
+ db_constraint=False,
291
+ null=True,
292
+ on_delete=django.db.models.deletion.DO_NOTHING,
293
+ related_name="+",
294
+ to="projects.project",
295
+ ),
296
+ ),
297
+ ],
298
+ options={
299
+ "verbose_name": "historical project team",
300
+ "verbose_name_plural": "historical project teams",
301
+ "ordering": ("-history_date", "-history_id"),
302
+ "get_latest_by": ("history_date", "history_id"),
303
+ },
304
+ bases=(simple_history.models.HistoricalChanges, models.Model),
305
+ ),
306
+ migrations.CreateModel(
307
+ name="ProjectTeam",
308
+ fields=[
309
+ (
310
+ "id",
311
+ models.BigAutoField(
312
+ auto_created=True,
313
+ primary_key=True,
314
+ serialize=False,
315
+ verbose_name="ID",
316
+ ),
317
+ ),
318
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
319
+ ("created_at", models.DateTimeField(auto_now_add=True)),
320
+ ("updated_at", models.DateTimeField(auto_now=True)),
321
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
322
+ (
323
+ "role",
324
+ models.CharField(
325
+ choices=[
326
+ ("read", "Read"),
327
+ ("write", "Write"),
328
+ ("admin", "Admin"),
329
+ ],
330
+ default="read",
331
+ max_length=10,
332
+ ),
333
+ ),
334
+ (
335
+ "created_by",
336
+ models.ForeignKey(
337
+ blank=True,
338
+ null=True,
339
+ on_delete=django.db.models.deletion.SET_NULL,
340
+ related_name="+",
341
+ to=settings.AUTH_USER_MODEL,
342
+ ),
343
+ ),
344
+ (
345
+ "deleted_by",
346
+ models.ForeignKey(
347
+ blank=True,
348
+ null=True,
349
+ on_delete=django.db.models.deletion.SET_NULL,
350
+ related_name="+",
351
+ to=settings.AUTH_USER_MODEL,
352
+ ),
353
+ ),
354
+ (
355
+ "project",
356
+ models.ForeignKey(
357
+ on_delete=django.db.models.deletion.CASCADE,
358
+ related_name="project_teams",
359
+ to="projects.project",
360
+ ),
361
+ ),
362
+ (
363
+ "team",
364
+ models.ForeignKey(
365
+ on_delete=django.db.models.deletion.CASCADE,
366
+ related_name="project_teams",
367
+ to="organization.team",
368
+ ),
369
+ ),
370
+ (
371
+ "updated_by",
372
+ models.ForeignKey(
373
+ blank=True,
374
+ null=True,
375
+ on_delete=django.db.models.deletion.SET_NULL,
376
+ related_name="+",
377
+ to=settings.AUTH_USER_MODEL,
378
+ ),
379
+ ),
380
+ ],
381
+ options={
382
+ "unique_together": {("project", "team")},
383
+ },
384
+ ),
385
+ migrations.AddField(
386
+ model_name="project",
387
+ name="teams",
388
+ field=models.ManyToManyField(
389
+ blank=True,
390
+ related_name="projects",
391
+ through="projects.ProjectTeam",
392
+ to="organization.team",
393
+ ),
394
+ ),
395
+ ]
--- a/projects/migrations/0001_initial.py
+++ b/projects/migrations/0001_initial.py
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/migrations/0001_initial.py
+++ b/projects/migrations/0001_initial.py
@@ -0,0 +1,395 @@
1 # Generated by Django 5.2.12 on 2026-04-06 01:11
2
3 import uuid
4
5 import django.db.models.deletion
6 import simple_history.models
7 from django.conf import settings
8 from django.db import migrations, models
9
10
11 class Migration(migrations.Migration):
12 initial = True
13
14 dependencies = [
15 ("organization", "0002_historicalteam_team"),
16 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17 ]
18
19 operations = [
20 migrations.CreateModel(
21 name="HistoricalProject",
22 fields=[
23 (
24 "id",
25 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26 ),
27 ("version", models.PositiveIntegerField(default=1, editable=False)),
28 ("created_at", models.DateTimeField(blank=True, editable=False)),
29 ("updated_at", models.DateTimeField(blank=True, editable=False)),
30 ("deleted_at", models.DateTimeField(blank=True, null=True)),
31 (
32 "guid",
33 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34 ),
35 ("name", models.CharField(max_length=200)),
36 ("slug", models.SlugField(max_length=200)),
37 ("description", models.TextField(blank=True, default="")),
38 (
39 "visibility",
40 models.CharField(
41 choices=[
42 ("public", "Public"),
43 ("internal", "Internal"),
44 ("private", "Private"),
45 ],
46 default="private",
47 max_length=10,
48 ),
49 ),
50 ("history_id", models.AutoField(primary_key=True, serialize=False)),
51 ("history_date", models.DateTimeField(db_index=True)),
52 ("history_change_reason", models.CharField(max_length=100, null=True)),
53 (
54 "history_type",
55 models.CharField(
56 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
57 max_length=1,
58 ),
59 ),
60 (
61 "created_by",
62 models.ForeignKey(
63 blank=True,
64 db_constraint=False,
65 null=True,
66 on_delete=django.db.models.deletion.DO_NOTHING,
67 related_name="+",
68 to=settings.AUTH_USER_MODEL,
69 ),
70 ),
71 (
72 "deleted_by",
73 models.ForeignKey(
74 blank=True,
75 db_constraint=False,
76 null=True,
77 on_delete=django.db.models.deletion.DO_NOTHING,
78 related_name="+",
79 to=settings.AUTH_USER_MODEL,
80 ),
81 ),
82 (
83 "history_user",
84 models.ForeignKey(
85 null=True,
86 on_delete=django.db.models.deletion.SET_NULL,
87 related_name="+",
88 to=settings.AUTH_USER_MODEL,
89 ),
90 ),
91 (
92 "organization",
93 models.ForeignKey(
94 blank=True,
95 db_constraint=False,
96 null=True,
97 on_delete=django.db.models.deletion.DO_NOTHING,
98 related_name="+",
99 to="organization.organization",
100 ),
101 ),
102 (
103 "updated_by",
104 models.ForeignKey(
105 blank=True,
106 db_constraint=False,
107 null=True,
108 on_delete=django.db.models.deletion.DO_NOTHING,
109 related_name="+",
110 to=settings.AUTH_USER_MODEL,
111 ),
112 ),
113 ],
114 options={
115 "verbose_name": "historical project",
116 "verbose_name_plural": "historical projects",
117 "ordering": ("-history_date", "-history_id"),
118 "get_latest_by": ("history_date", "history_id"),
119 },
120 bases=(simple_history.models.HistoricalChanges, models.Model),
121 ),
122 migrations.CreateModel(
123 name="Project",
124 fields=[
125 (
126 "id",
127 models.BigAutoField(
128 auto_created=True,
129 primary_key=True,
130 serialize=False,
131 verbose_name="ID",
132 ),
133 ),
134 ("version", models.PositiveIntegerField(default=1, editable=False)),
135 ("created_at", models.DateTimeField(auto_now_add=True)),
136 ("updated_at", models.DateTimeField(auto_now=True)),
137 ("deleted_at", models.DateTimeField(blank=True, null=True)),
138 (
139 "guid",
140 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
141 ),
142 ("name", models.CharField(max_length=200)),
143 ("slug", models.SlugField(max_length=200, unique=True)),
144 ("description", models.TextField(blank=True, default="")),
145 (
146 "visibility",
147 models.CharField(
148 choices=[
149 ("public", "Public"),
150 ("internal", "Internal"),
151 ("private", "Private"),
152 ],
153 default="private",
154 max_length=10,
155 ),
156 ),
157 (
158 "created_by",
159 models.ForeignKey(
160 blank=True,
161 null=True,
162 on_delete=django.db.models.deletion.SET_NULL,
163 related_name="+",
164 to=settings.AUTH_USER_MODEL,
165 ),
166 ),
167 (
168 "deleted_by",
169 models.ForeignKey(
170 blank=True,
171 null=True,
172 on_delete=django.db.models.deletion.SET_NULL,
173 related_name="+",
174 to=settings.AUTH_USER_MODEL,
175 ),
176 ),
177 (
178 "organization",
179 models.ForeignKey(
180 on_delete=django.db.models.deletion.CASCADE,
181 related_name="projects",
182 to="organization.organization",
183 ),
184 ),
185 (
186 "updated_by",
187 models.ForeignKey(
188 blank=True,
189 null=True,
190 on_delete=django.db.models.deletion.SET_NULL,
191 related_name="+",
192 to=settings.AUTH_USER_MODEL,
193 ),
194 ),
195 ],
196 options={
197 "ordering": ["name"],
198 },
199 ),
200 migrations.CreateModel(
201 name="HistoricalProjectTeam",
202 fields=[
203 (
204 "id",
205 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
206 ),
207 ("version", models.PositiveIntegerField(default=1, editable=False)),
208 ("created_at", models.DateTimeField(blank=True, editable=False)),
209 ("updated_at", models.DateTimeField(blank=True, editable=False)),
210 ("deleted_at", models.DateTimeField(blank=True, null=True)),
211 (
212 "role",
213 models.CharField(
214 choices=[
215 ("read", "Read"),
216 ("write", "Write"),
217 ("admin", "Admin"),
218 ],
219 default="read",
220 max_length=10,
221 ),
222 ),
223 ("history_id", models.AutoField(primary_key=True, serialize=False)),
224 ("history_date", models.DateTimeField(db_index=True)),
225 ("history_change_reason", models.CharField(max_length=100, null=True)),
226 (
227 "history_type",
228 models.CharField(
229 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
230 max_length=1,
231 ),
232 ),
233 (
234 "created_by",
235 models.ForeignKey(
236 blank=True,
237 db_constraint=False,
238 null=True,
239 on_delete=django.db.models.deletion.DO_NOTHING,
240 related_name="+",
241 to=settings.AUTH_USER_MODEL,
242 ),
243 ),
244 (
245 "deleted_by",
246 models.ForeignKey(
247 blank=True,
248 db_constraint=False,
249 null=True,
250 on_delete=django.db.models.deletion.DO_NOTHING,
251 related_name="+",
252 to=settings.AUTH_USER_MODEL,
253 ),
254 ),
255 (
256 "history_user",
257 models.ForeignKey(
258 null=True,
259 on_delete=django.db.models.deletion.SET_NULL,
260 related_name="+",
261 to=settings.AUTH_USER_MODEL,
262 ),
263 ),
264 (
265 "team",
266 models.ForeignKey(
267 blank=True,
268 db_constraint=False,
269 null=True,
270 on_delete=django.db.models.deletion.DO_NOTHING,
271 related_name="+",
272 to="organization.team",
273 ),
274 ),
275 (
276 "updated_by",
277 models.ForeignKey(
278 blank=True,
279 db_constraint=False,
280 null=True,
281 on_delete=django.db.models.deletion.DO_NOTHING,
282 related_name="+",
283 to=settings.AUTH_USER_MODEL,
284 ),
285 ),
286 (
287 "project",
288 models.ForeignKey(
289 blank=True,
290 db_constraint=False,
291 null=True,
292 on_delete=django.db.models.deletion.DO_NOTHING,
293 related_name="+",
294 to="projects.project",
295 ),
296 ),
297 ],
298 options={
299 "verbose_name": "historical project team",
300 "verbose_name_plural": "historical project teams",
301 "ordering": ("-history_date", "-history_id"),
302 "get_latest_by": ("history_date", "history_id"),
303 },
304 bases=(simple_history.models.HistoricalChanges, models.Model),
305 ),
306 migrations.CreateModel(
307 name="ProjectTeam",
308 fields=[
309 (
310 "id",
311 models.BigAutoField(
312 auto_created=True,
313 primary_key=True,
314 serialize=False,
315 verbose_name="ID",
316 ),
317 ),
318 ("version", models.PositiveIntegerField(default=1, editable=False)),
319 ("created_at", models.DateTimeField(auto_now_add=True)),
320 ("updated_at", models.DateTimeField(auto_now=True)),
321 ("deleted_at", models.DateTimeField(blank=True, null=True)),
322 (
323 "role",
324 models.CharField(
325 choices=[
326 ("read", "Read"),
327 ("write", "Write"),
328 ("admin", "Admin"),
329 ],
330 default="read",
331 max_length=10,
332 ),
333 ),
334 (
335 "created_by",
336 models.ForeignKey(
337 blank=True,
338 null=True,
339 on_delete=django.db.models.deletion.SET_NULL,
340 related_name="+",
341 to=settings.AUTH_USER_MODEL,
342 ),
343 ),
344 (
345 "deleted_by",
346 models.ForeignKey(
347 blank=True,
348 null=True,
349 on_delete=django.db.models.deletion.SET_NULL,
350 related_name="+",
351 to=settings.AUTH_USER_MODEL,
352 ),
353 ),
354 (
355 "project",
356 models.ForeignKey(
357 on_delete=django.db.models.deletion.CASCADE,
358 related_name="project_teams",
359 to="projects.project",
360 ),
361 ),
362 (
363 "team",
364 models.ForeignKey(
365 on_delete=django.db.models.deletion.CASCADE,
366 related_name="project_teams",
367 to="organization.team",
368 ),
369 ),
370 (
371 "updated_by",
372 models.ForeignKey(
373 blank=True,
374 null=True,
375 on_delete=django.db.models.deletion.SET_NULL,
376 related_name="+",
377 to=settings.AUTH_USER_MODEL,
378 ),
379 ),
380 ],
381 options={
382 "unique_together": {("project", "team")},
383 },
384 ),
385 migrations.AddField(
386 model_name="project",
387 name="teams",
388 field=models.ManyToManyField(
389 blank=True,
390 related_name="projects",
391 through="projects.ProjectTeam",
392 to="organization.team",
393 ),
394 ),
395 ]

No diff available

--- a/projects/models.py
+++ b/projects/models.py
@@ -0,0 +1,39 @@
1
+from django.db import models
2
+
3
+from core.models import ActiveManager, BaseCoreModen self.name
4
+
5
+
6
+class Project(BaseCoreModel):
7
+ class Visibility(models.TextChoices):
8
+ PUBLIC = "public", "Public"
9
+ INTERNAL = "internal", "Internal"
10
+ PRIVATE = "private", "Private"
11
+
12
+ organization = models.ForeignKey("organization.Organization", olated projects",
13
+ )
14
+ visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
15
+ teams = models.ManyToManyField("organization.Team", through="ProjectTeam", blank=True, related_name="projects")
16
+
17
+ objects = ActiveManager()
18
+ all_objects = models.Manager()
19
+
20
+ class Meta:
21
+
22
+class ProjectTeam(Tracking):
23
+ class Role(models.TextChoices):
24
+ READ = "read", "Read"
25
+ WRITE = "write", "Write"
26
+ ADMIN = "admin", "Admin"
27
+
28
+ project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="project_teams")
29
+ team = models.ForeignKey("organization.Team", on_delete=models.CASCADE, related_name="project_teams")
30
+ role = models.CharField(max_length=10, choices=Role.choices, default=Role.READ)
31
+
32
+ objects = ActiveManager()
33
+ all_objects = models.Manager()
34
+
35
+ class Meta:
36
+ unique_together = ("project", "team")
37
+
38
+ def __str__(self):
39
+ return f"{self.project}/{self.team} ({self.role})"
--- a/projects/models.py
+++ b/projects/models.py
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/models.py
+++ b/projects/models.py
@@ -0,0 +1,39 @@
1 from django.db import models
2
3 from core.models import ActiveManager, BaseCoreModen self.name
4
5
6 class Project(BaseCoreModel):
7 class Visibility(models.TextChoices):
8 PUBLIC = "public", "Public"
9 INTERNAL = "internal", "Internal"
10 PRIVATE = "private", "Private"
11
12 organization = models.ForeignKey("organization.Organization", olated projects",
13 )
14 visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
15 teams = models.ManyToManyField("organization.Team", through="ProjectTeam", blank=True, related_name="projects")
16
17 objects = ActiveManager()
18 all_objects = models.Manager()
19
20 class Meta:
21
22 class ProjectTeam(Tracking):
23 class Role(models.TextChoices):
24 READ = "read", "Read"
25 WRITE = "write", "Write"
26 ADMIN = "admin", "Admin"
27
28 project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="project_teams")
29 team = models.ForeignKey("organization.Team", on_delete=models.CASCADE, related_name="project_teams")
30 role = models.CharField(max_length=10, choices=Role.choices, default=Role.READ)
31
32 objects = ActiveManager()
33 all_objects = models.Manager()
34
35 class Meta:
36 unique_together = ("project", "team")
37
38 def __str__(self):
39 return f"{self.project}/{self.team} ({self.role})"
--- a/projects/tests.py
+++ b/projects/tests.py
@@ -0,0 +1,91 @@
1
+import pytest
2
+
3
+from .models import Project, ProjectTeam
4
+
5
+
6
+@pytest.mark.django_db
7
+class TestProjectModel:
8
+ def test_create_project(self, org, admin_user):
9
+ project = Project.objects.create(name="Test Project", organization=org, created_by=admin_user)
10
+ assert project.slug == "test-project"
11
+ assert project.guid is not None
12
+ assert project.visibility == "private"
13
+
14
+ def test_soft_delete_project(self, sample_project, admin_user):
15
+ sample_project.soft_delete(user=admin_user)
16
+ assert Project.objects.filter(slug=sample_project.slug).count() == 0
17
+ assert Project.all_objects.filter(slug=sample_project.slug).count() == 1
18
+
19
+
20
+@pytest.mark.django_db
21
+class TestProjectViews:
22
+ def test_project_list_renders(self, admin_client, sample_project):
23
+ response = admin_client.get("/projects/")
24
+ assert response.status_code == 200
25
+ assert sample_project.name in response.content.decode()
26
+
27
+ def test_project_list_htmx(self, admin_client, sample_project):
28
+ response = admin_client.get("/projects/", HTTP_HX_REQUEST="true")
29
+ assert response.status_code == 200
30
+ assert b"project-table" in response.content
31
+
32
+ def test_project_list_search(self, admin_client, sample_project):
33
+ response = admin_client.get("/projects/?search=Frontend")
34
+ assert response.status_code == 200
35
+f test_project_team_remove_import pytrt Project, ProjectTeam
36
+
37
+
38
+@pyt403
39
+
40
+ def test_project_create(self, admin_client, orgimport pytest
41
+
42
+from .mrt Project, Projecct.slug).count() == 1
43
+
44
+
45
+@pytestojects/create/", {"name": "New Project", "description": "Test", "visibility": "private"})
46
+ assert response.status_code == 302
47
+ assert Project.objects.filter(slug="new-project").exists()
48
+
49
+ def test_project_create_denied(self, no_perm_client, org):
50
+ response = no_perm_client.post("/projects/create/", {"name": "Hack"})
51
+ assert response.status_code == 403
52
+
53
+ def test_project_detail_renders(self, admin_client, sample_project):
54
+ response = admin_client.get(f"/projects/{sample_project.slug}/")
55
+ assert response.status_code == 200
56
+ assert sample_project.name in response.content.decode()
57
+
58
+ def test_project_detail_shows_teams(self, admin_client, sample_project, sample_team):
59
+ response = admin_client.get(f"/projects/{sample_project.slug}/")
60
+ assert sample_team.name in response.content.decode()
61
+
62
+ def test_project_update(self, admin_client, sample_project):
63
+ response = admin_client.post(
64
+ f"/projects/{sample_project.slug}/edit/",
65
+ {"name": "Updated Project", "description": "Updated", "visibility": "public"},
66
+ )
67
+ assert response.status_code == 302
68
+ sample_project.refresh_from_db()
69
+ assert sample_project.name == "Updated Project"
70
+ assert sample_project.visibility == "public"
71
+
72
+ def test_project_update_denied(self, no_perm_client, sample_project):
73
+ response = no_perm_client.post(f"/projects/{sample_project.slug}/edit/", {"name": "Hacked"})
74
+ assert response.status_code == 403
75
+
76
+ def test_project_delete(self, admin_client, sample_project):
77
+ response = admin_client.post(f"/projects/{sample_project.slug}/delete/")
78
+ assert response.status_code == 302
79
+ assert Project.objects.filter(slug=sample_project.slug).count() == 0
80
+
81
+ def test_project_delete_denied(self, no_perm_client, sample_project):
82
+ response = no_perm_client.post(f"/projects/{sample_project.slug}/delete/")
83
+ assert response.status_code == 403
84
+
85
+
86
+@pytest.mark.django_db
87
+class TestProjectTeamViews:
88
+ def test_project_team_add(self, admin_client, org, sample_project, admin_user):
89
+ from organization.models import Team
90
+
91
+ new_team = Team.objects.create(name="QA Team", organizatio
--- a/projects/tests.py
+++ b/projects/tests.py
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/tests.py
+++ b/projects/tests.py
@@ -0,0 +1,91 @@
1 import pytest
2
3 from .models import Project, ProjectTeam
4
5
6 @pytest.mark.django_db
7 class TestProjectModel:
8 def test_create_project(self, org, admin_user):
9 project = Project.objects.create(name="Test Project", organization=org, created_by=admin_user)
10 assert project.slug == "test-project"
11 assert project.guid is not None
12 assert project.visibility == "private"
13
14 def test_soft_delete_project(self, sample_project, admin_user):
15 sample_project.soft_delete(user=admin_user)
16 assert Project.objects.filter(slug=sample_project.slug).count() == 0
17 assert Project.all_objects.filter(slug=sample_project.slug).count() == 1
18
19
20 @pytest.mark.django_db
21 class TestProjectViews:
22 def test_project_list_renders(self, admin_client, sample_project):
23 response = admin_client.get("/projects/")
24 assert response.status_code == 200
25 assert sample_project.name in response.content.decode()
26
27 def test_project_list_htmx(self, admin_client, sample_project):
28 response = admin_client.get("/projects/", HTTP_HX_REQUEST="true")
29 assert response.status_code == 200
30 assert b"project-table" in response.content
31
32 def test_project_list_search(self, admin_client, sample_project):
33 response = admin_client.get("/projects/?search=Frontend")
34 assert response.status_code == 200
35 f test_project_team_remove_import pytrt Project, ProjectTeam
36
37
38 @pyt403
39
40 def test_project_create(self, admin_client, orgimport pytest
41
42 from .mrt Project, Projecct.slug).count() == 1
43
44
45 @pytestojects/create/", {"name": "New Project", "description": "Test", "visibility": "private"})
46 assert response.status_code == 302
47 assert Project.objects.filter(slug="new-project").exists()
48
49 def test_project_create_denied(self, no_perm_client, org):
50 response = no_perm_client.post("/projects/create/", {"name": "Hack"})
51 assert response.status_code == 403
52
53 def test_project_detail_renders(self, admin_client, sample_project):
54 response = admin_client.get(f"/projects/{sample_project.slug}/")
55 assert response.status_code == 200
56 assert sample_project.name in response.content.decode()
57
58 def test_project_detail_shows_teams(self, admin_client, sample_project, sample_team):
59 response = admin_client.get(f"/projects/{sample_project.slug}/")
60 assert sample_team.name in response.content.decode()
61
62 def test_project_update(self, admin_client, sample_project):
63 response = admin_client.post(
64 f"/projects/{sample_project.slug}/edit/",
65 {"name": "Updated Project", "description": "Updated", "visibility": "public"},
66 )
67 assert response.status_code == 302
68 sample_project.refresh_from_db()
69 assert sample_project.name == "Updated Project"
70 assert sample_project.visibility == "public"
71
72 def test_project_update_denied(self, no_perm_client, sample_project):
73 response = no_perm_client.post(f"/projects/{sample_project.slug}/edit/", {"name": "Hacked"})
74 assert response.status_code == 403
75
76 def test_project_delete(self, admin_client, sample_project):
77 response = admin_client.post(f"/projects/{sample_project.slug}/delete/")
78 assert response.status_code == 302
79 assert Project.objects.filter(slug=sample_project.slug).count() == 0
80
81 def test_project_delete_denied(self, no_perm_client, sample_project):
82 response = no_perm_client.post(f"/projects/{sample_project.slug}/delete/")
83 assert response.status_code == 403
84
85
86 @pytest.mark.django_db
87 class TestProjectTeamViews:
88 def test_project_team_add(self, admin_client, org, sample_project, admin_user):
89 from organization.models import Team
90
91 new_team = Team.objects.create(name="QA Team", organizatio
--- a/projects/urls.py
+++ b/projects/urls.py
@@ -0,0 +1,16 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "projects"
6
+
7
+urlpatterns = [
8
+ path("", views.project_list, name="list"),
9
+ path("create/", views.project_create, name="create"),
10
+ , name="toggle_star"),
11
+ path("<slug:slug>/", views.project_detail, name="detail"),
12
+ path("<slug:slug>/edit/", views.project_update, name="update"),
13
+ path("<slug:slug>/delete/", views.project_delete, name="delete"),
14
+ path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"),
15
+ path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"),
16
+ path("<slug:slug>/teams/<slug:team_slug>/remove/", views.project_team_remo
--- a/projects/urls.py
+++ b/projects/urls.py
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/urls.py
+++ b/projects/urls.py
@@ -0,0 +1,16 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "projects"
6
7 urlpatterns = [
8 path("", views.project_list, name="list"),
9 path("create/", views.project_create, name="create"),
10 , name="toggle_star"),
11 path("<slug:slug>/", views.project_detail, name="detail"),
12 path("<slug:slug>/edit/", views.project_update, name="update"),
13 path("<slug:slug>/delete/", views.project_delete, name="delete"),
14 path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"),
15 path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"),
16 path("<slug:slug>/teams/<slug:team_slug>/remove/", views.project_team_remo
--- a/projects/views.py
+++ b/projects/views.py
@@ -0,0 +1,152 @@
1
+from django.contrib import messages
2
+from django.contrib.auth.decorators import login_required
3
+from django.http import HttpResponse
4
+from django.shortcuts import get_object_or_404, redirect, render
5
+
6
+from core.permissions import P
7
+from organization.models import Team
8
+from organization.views import get_org
9
+
10
+from .forms import ProjectForm, ProjectTeamAddForm, ProjectTeamEditForm
11
+from .models import Project, ProjectTeam
12
+
13
+
14
+@login_required
15
+def project_list(request):
16
+ P.PROJECT_VIEW.check(request.user)
17
+ projects = Project.objects.all()
18
+
19
+ search = request.GET.get("search", "").strip()
20
+ if search:
21
+ projects = projects.filter(name__icontains=search)
22
+
23
+ if request.headers.get("HX-Request"):
24
+ return render(request, "projects/partials/project_table.html", {"projects": projects})
25
+
26
+ return render(request, "projects/project_list.html", {"projects": projects, "search": search})
27
+
28
+
29
+@login_required
30
+def project_create(request):
31
+ P.PROJECT_ADD.check(request.user)
32
+ org = get_org()
33
+
34
+ if request.method == "POST":
35
+ form = ProjectForm(request.POST)
36
+ if form.is_valid():
37
+ project = form.save(commit=False)
38
+ project.organization = org
39
+ project.created_by = request.user
40
+ project.save()
41
+ messages.success(request, f'Project "{project.name}" created.')
42
+ return redirect("projects:detail", slug=project.slug)
43
+ else:
44
+ form = ProjectForm()
45
+
46
+ return render(request, "projects/project_form.html", {"form": form, "title": "New Project"})
47
+
48
+
49
+@login_required
50
+def project_detail(request, slug):
51
+ P.PROJECT_VIEW.check(request.user)
52
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
53
+ project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
54
+ return render(request, "projects/project_detail.html", {"project": project, "project_teams": project_teams})
55
+
56
+
57
+@login_required
58
+def project_update(request, slug):
59
+ P.PROJECT_CHANGE.check(request.user)
60
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
61
+
62
+ if request.method == "POST":
63
+ form = ProjectForm(request.POST, instance=project)
64
+ if form.is_valid():
65
+ project = form.save(commit=False)
66
+ project.updated_by = request.user
67
+ project.save()
68
+ messages.success(request, f'Project "{project.name}" updated.')
69
+ return redirect("projects:detail", slug=project.slug)
70
+ else:
71
+ form = ProjectForm(instance=project)
72
+
73
+ return render(request, "projects/project_form.html", {"form": form, "project": project, "title": "Edit Project"})
74
+
75
+
76
+@login_required
77
+def project_delete(request, slug):
78
+ P.PROJECT_DELETE.check(request.user)
79
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
80
+
81
+ if request.method == "POST":
82
+ project.soft_delete(user=request.user)
83
+ messages.success(request, f'Project "{project.name}" deleted.')
84
+
85
+ if request.headers.get("HX-Request"):
86
+ return HttpResponse(status=200, headers={"HX-Redirect": "/projects/"})
87
+
88
+ return redirect("projects:list")
89
+
90
+ return render(request, "projects/project_confirm_delete.html", {"project": project})
91
+
92
+
93
+# --- Team assignment ---
94
+
95
+
96
+@login_required
97
+def project_team_add(request, slug):
98
+ P.PROJECT_CHANGE.check(request.user)
99
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
100
+
101
+ if request.method == "POST":
102
+ form = ProjectTeamAddForm(request.POST, project=project)
103
+ if form.is_valid():
104
+ team = form.cleaned_data["team"]
105
+ role = form.cleaned_data["role"]
106
+ ProjectTeam.objects.create(project=project, team=team, role=role, created_by=request.user)
107
+ messages.success(request, f'Team "{team.name}" added with {role} access.')
108
+ return redirect("projects:detail", slug=project.slug)
109
+ else:
110
+ form = ProjectTeamAddForm(project=project)
111
+
112
+ return render(request, "projects/project_team_add.html", {"form": form, "project": project})
113
+
114
+
115
+@login_required
116
+def project_team_edit(request, slug, team_slug):
117
+ P.PROJECT_CHANGE.check(request.user)
118
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
119
+ team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True)
120
+ project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True)
121
+
122
+ if request.method == "POST":
123
+ form = ProjectTeamEditForm(request.POST)
124
+ if form.is_valid():
125
+ project_team.role = form.cleaned_data["role"]
126
+ project_team.updated_by = request.user
127
+ project_team.save()
128
+ messages.success(request, f'Team "{team.name}" role updated to {project_team.role}.')
129
+ return redirect("projects:detail", slug=project.slug)
130
+ else:
131
+ form = ProjectTeamEditForm(initial={"role": project_team.role})
132
+
133
+ return render(request, "projects/project_team_edit.html", {"form": form, "project": project, "team": team})
134
+
135
+
136
+@login_required
137
+def project_team_remove(request, slug, team_slug):
138
+ P.PROJECT_CHANGE.check(request.user)
139
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
140
+ team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True)
141
+ project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True)
142
+
143
+ if request.method == "POST":
144
+ project_team.soft_delete(user=request.user)
145
+ messages.success(request, f'Team "{team.name}" removed from project.')
146
+
147
+ if request.headers.get("HX-Request"):
148
+ return HttpResponse(status=200, headers={"HX-Redirect": f"/projects/{project.slug}/"})
149
+
150
+ return redirect("projects:detail", slug=project.slug)
151
+
152
+ return render(request, "projects/project_team_confirm_remove.html", {"project": project, "team": team})
--- a/projects/views.py
+++ b/projects/views.py
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/views.py
+++ b/projects/views.py
@@ -0,0 +1,152 @@
1 from django.contrib import messages
2 from django.contrib.auth.decorators import login_required
3 from django.http import HttpResponse
4 from django.shortcuts import get_object_or_404, redirect, render
5
6 from core.permissions import P
7 from organization.models import Team
8 from organization.views import get_org
9
10 from .forms import ProjectForm, ProjectTeamAddForm, ProjectTeamEditForm
11 from .models import Project, ProjectTeam
12
13
14 @login_required
15 def project_list(request):
16 P.PROJECT_VIEW.check(request.user)
17 projects = Project.objects.all()
18
19 search = request.GET.get("search", "").strip()
20 if search:
21 projects = projects.filter(name__icontains=search)
22
23 if request.headers.get("HX-Request"):
24 return render(request, "projects/partials/project_table.html", {"projects": projects})
25
26 return render(request, "projects/project_list.html", {"projects": projects, "search": search})
27
28
29 @login_required
30 def project_create(request):
31 P.PROJECT_ADD.check(request.user)
32 org = get_org()
33
34 if request.method == "POST":
35 form = ProjectForm(request.POST)
36 if form.is_valid():
37 project = form.save(commit=False)
38 project.organization = org
39 project.created_by = request.user
40 project.save()
41 messages.success(request, f'Project "{project.name}" created.')
42 return redirect("projects:detail", slug=project.slug)
43 else:
44 form = ProjectForm()
45
46 return render(request, "projects/project_form.html", {"form": form, "title": "New Project"})
47
48
49 @login_required
50 def project_detail(request, slug):
51 P.PROJECT_VIEW.check(request.user)
52 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
53 project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
54 return render(request, "projects/project_detail.html", {"project": project, "project_teams": project_teams})
55
56
57 @login_required
58 def project_update(request, slug):
59 P.PROJECT_CHANGE.check(request.user)
60 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
61
62 if request.method == "POST":
63 form = ProjectForm(request.POST, instance=project)
64 if form.is_valid():
65 project = form.save(commit=False)
66 project.updated_by = request.user
67 project.save()
68 messages.success(request, f'Project "{project.name}" updated.')
69 return redirect("projects:detail", slug=project.slug)
70 else:
71 form = ProjectForm(instance=project)
72
73 return render(request, "projects/project_form.html", {"form": form, "project": project, "title": "Edit Project"})
74
75
76 @login_required
77 def project_delete(request, slug):
78 P.PROJECT_DELETE.check(request.user)
79 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
80
81 if request.method == "POST":
82 project.soft_delete(user=request.user)
83 messages.success(request, f'Project "{project.name}" deleted.')
84
85 if request.headers.get("HX-Request"):
86 return HttpResponse(status=200, headers={"HX-Redirect": "/projects/"})
87
88 return redirect("projects:list")
89
90 return render(request, "projects/project_confirm_delete.html", {"project": project})
91
92
93 # --- Team assignment ---
94
95
96 @login_required
97 def project_team_add(request, slug):
98 P.PROJECT_CHANGE.check(request.user)
99 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
100
101 if request.method == "POST":
102 form = ProjectTeamAddForm(request.POST, project=project)
103 if form.is_valid():
104 team = form.cleaned_data["team"]
105 role = form.cleaned_data["role"]
106 ProjectTeam.objects.create(project=project, team=team, role=role, created_by=request.user)
107 messages.success(request, f'Team "{team.name}" added with {role} access.')
108 return redirect("projects:detail", slug=project.slug)
109 else:
110 form = ProjectTeamAddForm(project=project)
111
112 return render(request, "projects/project_team_add.html", {"form": form, "project": project})
113
114
115 @login_required
116 def project_team_edit(request, slug, team_slug):
117 P.PROJECT_CHANGE.check(request.user)
118 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
119 team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True)
120 project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True)
121
122 if request.method == "POST":
123 form = ProjectTeamEditForm(request.POST)
124 if form.is_valid():
125 project_team.role = form.cleaned_data["role"]
126 project_team.updated_by = request.user
127 project_team.save()
128 messages.success(request, f'Team "{team.name}" role updated to {project_team.role}.')
129 return redirect("projects:detail", slug=project.slug)
130 else:
131 form = ProjectTeamEditForm(initial={"role": project_team.role})
132
133 return render(request, "projects/project_team_edit.html", {"form": form, "project": project, "team": team})
134
135
136 @login_required
137 def project_team_remove(request, slug, team_slug):
138 P.PROJECT_CHANGE.check(request.user)
139 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
140 team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True)
141 project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True)
142
143 if request.method == "POST":
144 project_team.soft_delete(user=request.user)
145 messages.success(request, f'Team "{team.name}" removed from project.')
146
147 if request.headers.get("HX-Request"):
148 return HttpResponse(status=200, headers={"HX-Redirect": f"/projects/{project.slug}/"})
149
150 return redirect("projects:detail", slug=project.slug)
151
152 return render(request, "projects/project_team_confirm_remove.html", {"project": project, "team": team})
+15 -5
--- pyproject.toml
+++ pyproject.toml
@@ -1,9 +1,10 @@
11
[project]
2
-name = "fossilrepo-django-htmx"
2
+name = "fossilrepo"
33
version = "0.1.0"
4
-description = "Fossilrepo Django + HTMX template — server-rendered with progressive enhancement"
4
+description = "Omnibus-style installer for a self-hosted Fossil forge."
5
+license = "MIT"
56
requires-python = ">=3.12"
67
dependencies = [
78
"django>=5.1,<6.0",
89
"psycopg2-binary>=2.9",
910
"redis>=5.0",
@@ -20,12 +21,18 @@
2021
"django-cors-headers>=4.4",
2122
"gunicorn>=23.0",
2223
"whitenoise>=6.7",
2324
"boto3>=1.35",
2425
"sentry-sdk[django]>=2.14",
26
+ "click>=8.1",
27
+ "rich>=13.0",
28
+ "markdown>=3.6",
2529
]
2630
31
+[project.scripts]
32
+fossilrepo-ctl = "ctl.main:cli"
33
+
2734
[project.optional-dependencies]
2835
dev = [
2936
"ruff>=0.7",
3037
"pytest>=8.3",
3138
"pytest-django>=4.9",
@@ -42,11 +49,11 @@
4249
[tool.ruff.lint]
4350
select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"]
4451
ignore = ["E501"]
4552
4653
[tool.ruff.lint.isort]
47
-known-first-party = ["config", "core", "auth1", "organization", "items", "testdata"]
54
+known-first-party = ["config", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "testdata", "ctl"]
4855
4956
[tool.ruff.format]
5057
quote-style = "double"
5158
5259
[tool.pytest.ini_options]
@@ -55,15 +62,18 @@
5562
python_classes = ["Test*"]
5663
python_functions = ["test_*"]
5764
addopts = "-v --tb=short --strict-markers"
5865
5966
[tool.coverage.run]
60
-source = ["core", "auth1", "organization", "items"]
67
+source = ["core", "auth1", "organization", "items", "projects", "pages", "fossil"]
6168
omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"]
6269
6370
[tool.coverage.report]
6471
fail_under = 80
6572
show_missing = true
6673
74
+[tool.hatch.build.targets.wheel]
75
+packages = ["ctl", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "config"]
76
+
6777
[build-system]
6878
requires = ["hatchling"]
69
-build-backend = "hatchling.backends"
79
+build-backend = "hatchling.build"
7080
--- pyproject.toml
+++ pyproject.toml
@@ -1,9 +1,10 @@
1 [project]
2 name = "fossilrepo-django-htmx"
3 version = "0.1.0"
4 description = "Fossilrepo Django + HTMX template — server-rendered with progressive enhancement"
 
5 requires-python = ">=3.12"
6 dependencies = [
7 "django>=5.1,<6.0",
8 "psycopg2-binary>=2.9",
9 "redis>=5.0",
@@ -20,12 +21,18 @@
20 "django-cors-headers>=4.4",
21 "gunicorn>=23.0",
22 "whitenoise>=6.7",
23 "boto3>=1.35",
24 "sentry-sdk[django]>=2.14",
 
 
 
25 ]
26
 
 
 
27 [project.optional-dependencies]
28 dev = [
29 "ruff>=0.7",
30 "pytest>=8.3",
31 "pytest-django>=4.9",
@@ -42,11 +49,11 @@
42 [tool.ruff.lint]
43 select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"]
44 ignore = ["E501"]
45
46 [tool.ruff.lint.isort]
47 known-first-party = ["config", "core", "auth1", "organization", "items", "testdata"]
48
49 [tool.ruff.format]
50 quote-style = "double"
51
52 [tool.pytest.ini_options]
@@ -55,15 +62,18 @@
55 python_classes = ["Test*"]
56 python_functions = ["test_*"]
57 addopts = "-v --tb=short --strict-markers"
58
59 [tool.coverage.run]
60 source = ["core", "auth1", "organization", "items"]
61 omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"]
62
63 [tool.coverage.report]
64 fail_under = 80
65 show_missing = true
66
 
 
 
67 [build-system]
68 requires = ["hatchling"]
69 build-backend = "hatchling.backends"
70
--- pyproject.toml
+++ pyproject.toml
@@ -1,9 +1,10 @@
1 [project]
2 name = "fossilrepo"
3 version = "0.1.0"
4 description = "Omnibus-style installer for a self-hosted Fossil forge."
5 license = "MIT"
6 requires-python = ">=3.12"
7 dependencies = [
8 "django>=5.1,<6.0",
9 "psycopg2-binary>=2.9",
10 "redis>=5.0",
@@ -20,12 +21,18 @@
21 "django-cors-headers>=4.4",
22 "gunicorn>=23.0",
23 "whitenoise>=6.7",
24 "boto3>=1.35",
25 "sentry-sdk[django]>=2.14",
26 "click>=8.1",
27 "rich>=13.0",
28 "markdown>=3.6",
29 ]
30
31 [project.scripts]
32 fossilrepo-ctl = "ctl.main:cli"
33
34 [project.optional-dependencies]
35 dev = [
36 "ruff>=0.7",
37 "pytest>=8.3",
38 "pytest-django>=4.9",
@@ -42,11 +49,11 @@
49 [tool.ruff.lint]
50 select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"]
51 ignore = ["E501"]
52
53 [tool.ruff.lint.isort]
54 known-first-party = ["config", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "testdata", "ctl"]
55
56 [tool.ruff.format]
57 quote-style = "double"
58
59 [tool.pytest.ini_options]
@@ -55,15 +62,18 @@
62 python_classes = ["Test*"]
63 python_functions = ["test_*"]
64 addopts = "-v --tb=short --strict-markers"
65
66 [tool.coverage.run]
67 source = ["core", "auth1", "organization", "items", "projects", "pages", "fossil"]
68 omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"]
69
70 [tool.coverage.report]
71 fail_under = 80
72 show_missing = true
73
74 [tool.hatch.build.targets.wheel]
75 packages = ["ctl", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "config"]
76
77 [build-system]
78 requires = ["hatchling"]
79 build-backend = "hatchling.build"
80
--- static/admin/css/dark_theme.css
+++ static/admin/css/dark_theme.css
@@ -365,14 +365,15 @@
365365
366366
.module h2,
367367
.module caption,
368368
.inline-group h2 {
369369
background: var(--darkened-bg);
370
- color: var(--body-quiet-color);
370
+ color: var(--body-loud-color);
371371
border-left: 3px solid var(--primary);
372372
padding-left: 12px;
373373
font-size: 0.75em;
374
+ font-weight: 600;
374375
text-transform: uppercase;
375376
letter-spacing: 0.06em;
376377
}
377378
378379
.module {
379380
380381
ADDED static/admin/img/logo-dark.png
--- static/admin/css/dark_theme.css
+++ static/admin/css/dark_theme.css
@@ -365,14 +365,15 @@
365
366 .module h2,
367 .module caption,
368 .inline-group h2 {
369 background: var(--darkened-bg);
370 color: var(--body-quiet-color);
371 border-left: 3px solid var(--primary);
372 padding-left: 12px;
373 font-size: 0.75em;
 
374 text-transform: uppercase;
375 letter-spacing: 0.06em;
376 }
377
378 .module {
379
380 DDED static/admin/img/logo-dark.png
--- static/admin/css/dark_theme.css
+++ static/admin/css/dark_theme.css
@@ -365,14 +365,15 @@
365
366 .module h2,
367 .module caption,
368 .inline-group h2 {
369 background: var(--darkened-bg);
370 color: var(--body-loud-color);
371 border-left: 3px solid var(--primary);
372 padding-left: 12px;
373 font-size: 0.75em;
374 font-weight: 600;
375 text-transform: uppercase;
376 letter-spacing: 0.06em;
377 }
378
379 .module {
380
381 DDED static/admin/img/logo-dark.png
--- static/img/fossilrepo-logo-dark.png
+++ static/img/fossilrepo-logo-dark.png
cannot compute difference between binary files
11
--- static/img/fossilrepo-logo-dark.png
+++ static/img/fossilrepo-logo-dark.png
0 annot compute difference between binary files
1
--- static/img/fossilrepo-logo-dark.png
+++ static/img/fossilrepo-logo-dark.png
0 annot compute difference between binary files
1
--- static/img/fossilrepo-logo-dark.svg
+++ static/img/fossilrepo-logo-dark.svg
@@ -1,110 +1,13 @@
1
-<svg width="1033" height="213" viewBox="0 0 1033 213" fill="none" xmlns="http://www.w3.org/2000/svg">
2
-<g filter="url(#filter0_d_303_614)">
3
-<rect x="4" width="1025" height="205" fill="#2B2D2C"/>
4
-<path d="M491.56 167H500.064V163.116H495.59V132.884H500.138V129H491.56V167Z" fill="#DB394C"/>
5
-<path d="M506.018 129V132.884H510.529V163.116H505.981V167H514.56V129H506.018Z" fill="#DB394C"/>
6
-<path d="M505.093 139.932H501.026V155.788H505.093V139.932Z" fill="#DB394C"/>
7
-<path d="M148.624 101.062C168.554 83.5327 166.323 44.969 144.11 30.1725C134.17 22.9805 119.767 19.0365 107.519 19.1396C101.434 19.6293 95.7028 22.4134 90.7074 26.048C84.6217 30.5592 79.7024 35.9209 72.9573 40.2774C65.1219 44.969 54.1422 50.4597 44.0754 49.2223C37.3303 47.3921 37.1782 40.8703 37.4825 34.9929C37.4825 34.9414 37.4825 34.8641 37.4825 34.8125C37.2796 34.8125 37.1275 34.8641 36.9246 34.8641C34.4903 34.8641 32.4871 34.1681 30.8896 33.0854C30.8135 33.7041 30.7374 34.3485 30.7374 34.9929C29.6724 46.129 34.2367 55.873 46.2814 56.3628C61.9776 56.5175 77.0145 46.7734 88.0956 36.9005C97.5793 27.9556 106.125 23.3156 119.006 26.7956C132.522 29.1929 144.871 36.1014 151.311 48.6294C160.034 65.849 155.216 92.9158 136.477 101.01C155.216 109.079 160.034 136.171 151.311 153.391C144.871 165.919 132.395 172.518 118.879 174.915C105.947 178.395 97.63 174.09 88.0956 165.12C77.0145 155.247 61.9523 145.503 46.2814 145.657C34.2367 146.199 29.6724 155.865 30.7374 167.053C30.7374 173.523 34.5917 179.117 40.0943 181.54C40.0182 180.354 40.0943 179.194 40.3986 178.112C40.7536 176.849 41.2861 175.766 41.9454 174.812C39.2575 173.265 37.4317 170.378 37.4317 167.053C37.1275 161.201 37.2542 154.654 44.0246 152.824C54.0661 151.586 65.0459 157.077 72.9066 161.768C79.6517 166.125 84.571 171.513 90.6567 175.998C95.6521 179.633 101.383 182.417 107.469 182.906C119.716 183.009 134.119 179.065 144.059 171.873C166.272 157.103 168.529 118.539 148.573 100.984L148.624 101.062Z" fill="#DC394C"/>
8
-<path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="#DC394C"/>
9
-<g opacity="0.2">
10
-<path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="url(#paint0_linear_303_614)"/>
11
-</g>
12
-<path d="M37.7352 41.4374C37.4056 39.3236 37.4563 37.0294 37.5577 34.8382C37.5577 29.8889 41.5135 25.8676 46.3821 25.8676H79.5241C80.158 25.8676 80.7412 25.6356 81.223 25.2231C83.6827 23.1093 86.8523 20.7893 89.8445 19.0364H46.4074C37.8367 19.0364 30.8634 26.1253 30.8634 34.8382C30.483 38.7823 30.8381 42.5716 31.9538 45.8196C33.3991 43.7574 35.4277 42.1076 37.7606 41.4632L37.7352 41.4374Z" fill="url(#paint1_linear_303_614)"/>
13
-<path d="M37.7352 160.402C37.4056 162.516 37.4563 164.81 37.5577 167.001C37.5577 171.951 41.5135 175.972 46.3821 175.972H79.5241C80.158 175.972 80.7412 176.204 81.223 176.617C83.6827 178.73 86.8523 181.05 89.8445 182.803H46.4074C37.8367 182.803 30.8634 175.714 30.8634 167.001C30.483 163.057 30.8381 159.268 31.9538 156.02C33.3991 158.082 35.4277 159.732 37.7606 160.376L37.7352 160.402Z" fill="url(#paint2_linear_303_614)"/>
14
-<path d="M102.042 111.167C102.042 111.167 35.8849 111.167 30.5852 111.167V117.998H102.042C107.494 117.998 111.779 119.183 114.848 121.555C119.615 124.88 120.604 139.11 114.366 142.719C106.48 148.209 82.0859 144.781 72.8051 145.683C76.5833 147.436 80.3616 149.292 83.6327 151.483C84.5962 152.128 85.712 152.488 86.853 152.463C103.437 152.179 125.929 157.129 125.396 132.665C125.599 117.276 115.938 111.321 102.042 111.167Z" fill="url(#paint3_linear_303_614)"/>
15
-<path d="M87.7147 49.2997C86.3454 49.2997 84.9761 49.6606 83.8097 50.4339C80.6146 52.5735 76.9125 54.3779 73.2103 56.0793C82.77 56.9815 106.226 53.5273 114.34 59.0437C120.578 62.6526 119.563 76.9078 114.822 80.2074C111.753 82.5531 107.468 83.7647 102.016 83.7647H30.5593V90.5958H102.016C115.912 90.4669 125.573 84.4865 125.37 69.0971C125.928 44.8401 104.324 49.5059 87.6893 49.2997H87.7147Z" fill="url(#paint4_linear_303_614)"/>
16
-<path d="M121.263 126.427C120.198 126.608 119.133 126.814 118.068 127.072C119.539 132.614 118.702 140.218 114.391 142.744C106.505 148.235 82.1114 144.807 72.8306 145.709C76.6088 147.462 80.3871 149.318 83.6582 151.509C84.6217 152.153 85.7375 152.514 86.8785 152.489C103.462 152.205 125.954 157.154 125.422 132.691C125.447 130.062 125.168 127.768 124.661 125.654C123.596 126.04 122.48 126.298 121.288 126.427H121.263Z" fill="url(#paint5_linear_303_614)"/>
17
-<g filter="url(#filter1_d_303_614)">
18
-<rect x="195.06" y="32.5" width="803.841" height="76.1916" stroke="#2B2D2C" shape-rendering="crispEdges"/>
19
-<path fill-rule="evenodd" clip-rule="evenodd" d="M549.696 108.274V32.9176H582.786C588.268 32.9176 593.264 33.9167 597.661 36.0512C602.078 38.1951 605.594 41.3351 608.098 45.4472C610.637 49.6163 611.813 54.4753 611.813 59.8467C611.813 65.2685 610.6 70.1409 607.941 74.2414C606.304 76.7879 604.248 78.9243 601.812 80.6522L616.857 108.274H587.652L576.157 86.6632V108.274H549.696ZM590.127 104.154H609.921L596.291 79.1301C597.57 78.5163 598.748 77.8075 599.824 77.0036C601.677 75.6192 603.23 73.9531 604.481 72.005C606.622 68.706 607.693 64.6532 607.693 59.8467C607.693 55.0838 606.655 50.9983 604.579 47.59C602.504 44.1817 599.598 41.5709 595.862 39.7576C592.126 37.9442 587.768 37.0375 582.786 37.0375H553.816V104.154H572.037V82.0004H578.343L590.127 104.154ZM572.037 67.843H578.46C580.601 67.843 582.426 67.5808 583.933 67.0565C585.463 66.5103 586.631 65.6473 587.44 64.4675C588.27 63.2877 588.685 61.7474 588.685 59.8467C588.685 57.9241 588.27 56.3619 587.44 55.1603C586.631 53.9368 585.463 53.0411 583.933 52.473C582.426 51.8831 580.601 51.5882 578.46 51.5882H572.037V67.843ZM576.157 55.7081V63.7231H578.46C580.277 63.7231 581.612 63.499 582.563 63.171C583.38 62.8765 583.79 62.5051 584.041 62.1388L584.056 62.1176L584.071 62.0965C584.278 61.8011 584.565 61.1655 584.565 59.8467C584.565 58.4992 584.275 57.8278 584.05 57.5022L584.026 57.467L584.002 57.4314C583.73 57.019 583.305 56.6347 582.499 56.3351L582.465 56.3227L582.432 56.3096C581.53 55.9566 580.244 55.7081 578.46 55.7081H576.157Z" fill="#FAFAFA"/>
20
-<path fill-rule="evenodd" clip-rule="evenodd" d="M475.677 108.274V32.9176H532.288V55.8391H502.138V59.1351H529.797V82.0566H502.138V85.3525H532.157V108.274H475.677ZM498.018 89.4724V77.9367H525.677V63.2549H498.018V51.7193H528.168V37.0375H479.797V104.154H528.037V89.4724H498.018Z" fill="#FAFAFA"/>
21
-<path fill-rule="evenodd" clip-rule="evenodd" d="M406.344 108.274V32.9176H432.805V85.3525H459.94V108.274H406.344ZM428.685 89.4724V37.0375H410.464V104.154H455.82V89.4724H428.685Z" fill="#FAFAFA"/>
22
-<path fill-rule="evenodd" clip-rule="evenodd" d="M389.337 32.9176V108.274H362.876V32.9176H389.337ZM385.217 37.0375H366.996V104.154H385.217V37.0375Z" fill="#FAFAFA"/>
23
-<path fill-rule="evenodd" clip-rule="evenodd" d="M341.181 91.5344L341.177 91.5409C337.907 97.2938 333.433 101.742 327.775 104.771L327.768 104.775L327.762 104.778C322.179 107.742 316.001 109.192 309.313 109.192C302.581 109.192 296.376 107.731 290.786 104.738L290.776 104.733L290.767 104.728C285.138 101.676 280.682 97.2211 277.416 91.4754L277.411 91.4663L277.406 91.4573C274.097 85.5658 272.552 78.5492 272.552 70.5958C272.552 62.6044 274.095 55.5718 277.41 49.6951L277.413 49.69C280.679 43.9181 285.143 39.4637 290.789 36.4517C296.378 33.46 302.582 32 309.313 32C316.004 32 322.185 33.4617 327.769 36.4501C333.435 39.4604 337.911 43.9139 341.18 49.6886C344.519 55.5668 346.074 62.602 346.074 70.5958C346.074 78.5928 344.518 85.6372 341.181 91.5344ZM325.83 40.0853C320.892 37.4417 315.387 36.1199 309.313 36.1199C303.196 36.1199 297.668 37.4417 292.731 40.0853C287.815 42.707 283.904 46.585 280.998 51.7193C278.114 56.8317 276.672 63.1239 276.672 70.5958C276.672 78.0241 278.114 84.3054 280.998 89.4396C283.904 94.552 287.815 98.4409 292.731 101.106C297.668 103.75 303.196 105.072 309.313 105.072C315.387 105.072 320.892 103.761 325.83 101.139C330.768 98.4956 334.689 94.6176 337.595 89.5052C340.501 84.3709 341.954 78.0678 341.954 70.5958C341.954 63.1239 340.501 56.8317 337.595 51.7193C334.689 46.585 330.768 42.707 325.83 40.0853ZM317.756 61.9115L317.749 61.8931C316.943 59.7622 315.851 58.4077 314.59 57.5445L314.569 57.5305L314.549 57.5161C313.319 56.6524 311.655 56.1013 309.313 56.1013C306.973 56.1013 305.289 56.6519 304.028 57.5279L304.02 57.533C302.77 58.3979 301.668 59.7627 300.838 61.9119C300.021 64.0587 299.538 66.9113 299.538 70.5958C299.538 74.2732 300.019 77.1389 300.838 79.3129C301.662 81.4232 302.759 82.7991 304.024 83.6938C305.285 84.5503 306.973 85.0903 309.313 85.0903C311.665 85.0903 313.337 84.545 314.569 83.6941C315.854 82.8 316.95 81.4286 317.753 79.3212L317.756 79.313L317.759 79.3048C318.595 77.1376 319.088 74.2759 319.088 70.5958C319.088 66.9141 318.594 64.0686 317.763 61.9298L317.756 61.9115ZM316.916 87.08C314.862 88.5002 312.328 89.2102 309.313 89.2102C306.298 89.2102 303.753 88.5002 301.677 87.08C299.624 85.6381 298.061 83.5407 296.991 80.7879C295.942 78.0132 295.418 74.6158 295.418 70.5958C295.418 66.5758 295.942 63.1894 296.991 60.4366C298.061 57.6619 299.624 55.5645 301.677 54.1444C303.753 52.7024 306.298 51.9814 309.313 51.9814C312.328 51.9814 314.862 52.7024 316.916 54.1444C318.992 55.5645 320.554 57.6619 321.602 60.4366C322.673 63.1894 323.208 66.5758 323.208 70.5958C323.208 74.6158 322.673 78.0132 321.602 80.7879C320.554 83.5407 318.992 85.6381 316.916 87.08Z" fill="#FAFAFA"/>
24
-<path fill-rule="evenodd" clip-rule="evenodd" d="M194.56 108.274V32.9176H227.781C233.232 32.9176 238.075 33.6472 242.173 35.2702C246.242 36.8731 249.59 39.253 251.916 42.5486C254.255 45.8309 255.365 49.6469 255.365 53.8167C255.365 56.9112 254.675 59.8338 253.244 62.5031C252.104 64.6623 250.581 66.5265 248.711 68.0855C251.447 69.7483 253.658 71.9966 255.309 74.7798C257.146 77.8531 257.987 81.3596 257.987 85.1465C257.987 89.576 256.814 93.6497 254.409 97.2336C252.037 100.802 248.706 103.516 244.595 105.431C240.412 107.379 235.595 108.274 230.271 108.274H194.56ZM244.445 66.1032C244.662 65.967 244.875 65.8259 245.084 65.68C247.029 64.3036 248.536 62.5995 249.607 60.5676C250.699 58.5358 251.245 56.2855 251.245 53.8167C251.245 50.4084 250.35 47.448 248.558 44.9355C246.788 42.423 244.156 40.4785 240.66 39.1021C237.186 37.7257 232.893 37.0375 227.781 37.0375H198.679V104.154H230.271C235.143 104.154 239.338 103.335 242.856 101.696C246.373 100.058 249.082 97.8073 250.983 94.9453C252.906 92.0832 253.867 88.8169 253.867 85.1465C253.867 81.9786 253.168 79.2257 251.77 76.888C250.371 74.5284 248.493 72.6823 246.133 71.3496C245.682 71.0907 245.222 70.8546 244.752 70.6413C242.8 69.7553 240.683 69.2613 238.401 69.1594C238.357 69.1574 238.312 69.1556 238.268 69.1539V68.4984C238.276 68.4967 238.284 68.4951 238.293 68.4934C240.602 68.0248 242.653 67.2281 244.445 66.1032ZM228.809 80.0833L228.797 80.0783C228.133 79.7907 227.138 79.5659 225.683 79.5659H221.02V85.4836H225.421C228.065 85.4836 229.319 84.9764 229.832 84.6027C230.143 84.3635 230.477 83.9794 230.477 82.7869C230.477 81.8557 230.271 81.3515 230.077 81.0582C229.849 80.7117 229.491 80.3743 228.82 80.0881L228.809 80.0833ZM232.303 87.8993C230.774 89.0354 228.48 89.6035 225.421 89.6035H216.901V75.446H225.683C227.54 75.446 229.124 75.7301 230.435 76.2981C231.768 76.8662 232.795 77.6964 233.516 78.7888C234.237 79.8812 234.597 81.2139 234.597 82.7869C234.597 85.0373 233.833 86.7414 232.303 87.8993ZM228.114 58.8396L228.119 58.8327C228.228 58.6826 228.38 58.3975 228.38 57.7493C228.38 56.7949 228.117 56.5269 227.831 56.3166L227.815 56.305L227.799 56.2933C227.174 55.8244 226.219 55.4459 224.635 55.4459H221.02V60.0527H224.372C225.538 60.0527 226.399 59.869 227.035 59.6078C227.65 59.3551 227.943 59.0772 228.109 58.8465L228.114 58.8396ZM224.372 64.1726C225.967 64.1726 227.377 63.9213 228.6 63.4188C229.824 62.9163 230.774 62.1953 231.451 61.2559C232.15 60.2946 232.5 59.1257 232.5 57.7493C232.5 55.6737 231.757 54.0898 230.271 52.9974C228.786 51.8831 226.907 51.326 224.635 51.326H216.901V64.1726H224.372Z" fill="#FAFAFA"/>
25
-<path d="M979.577 55.6468C979.388 53.2869 978.503 51.4461 976.922 50.1245C975.365 48.8029 972.993 48.1421 969.807 48.1421C967.777 48.1421 966.114 48.3899 964.816 48.8855C963.541 49.3575 962.597 50.0065 961.984 50.8325C961.37 51.6585 961.051 52.6025 961.028 53.6645C960.981 54.5377 961.134 55.3282 961.488 56.0362C961.866 56.7206 962.456 57.346 963.258 57.9124C964.06 58.4552 965.087 58.9508 966.338 59.3992C967.589 59.8476 969.075 60.2488 970.798 60.6028L976.745 61.8771C980.757 62.7267 984.191 63.8477 987.046 65.2401C989.902 66.6325 992.238 68.2726 994.056 70.1606C995.873 72.025 997.206 74.1253 998.056 76.4617C998.929 78.7981 999.377 81.3468 999.401 84.108C999.377 88.8751 998.185 92.9107 995.826 96.2146C993.466 99.5185 990.091 102.032 985.701 103.755C981.335 105.477 976.084 106.339 969.948 106.339C963.647 106.339 958.149 105.407 953.452 103.542C948.78 101.678 945.145 98.8106 942.549 94.9402C939.977 91.0463 938.679 86.0668 938.655 80.0017H957.346C957.464 82.22 958.019 84.0844 959.01 85.5948C960.001 87.1051 961.394 88.2497 963.187 89.0285C965.004 89.8073 967.164 90.1967 969.665 90.1967C971.766 90.1967 973.524 89.9371 974.94 89.4179C976.356 88.8987 977.43 88.1789 978.161 87.2585C978.893 86.3382 979.27 85.288 979.294 84.108C979.27 82.9988 978.905 82.0312 978.197 81.2052C977.512 80.3557 976.379 79.6005 974.798 78.9397C973.217 78.2553 971.081 77.6181 968.391 77.0281L961.169 75.4705C954.75 74.0781 949.688 71.7536 945.983 68.4968C942.302 65.2165 940.473 60.7444 940.496 55.0804C940.473 50.4785 941.7 46.4548 944.178 43.0092C946.679 39.5401 950.137 36.8379 954.55 34.9028C958.986 32.9676 964.072 32 969.807 32C975.66 32 980.722 32.9794 984.993 34.9382C989.265 36.8969 992.557 39.6581 994.87 43.2216C997.206 46.7616 998.386 50.9033 998.41 55.6468H979.577Z" fill="#FAFAFA"/>
26
-<path d="M870.211 105.489V32.9912H889.893V62.3019H890.884L912.69 32.9912H935.629L911.132 65.2755L936.195 105.489H912.69L896.406 78.3025L889.893 86.7983V105.489H870.211Z" fill="#FAFAFA"/>
27
-<path d="M803.624 105.489V32.9912H834.917C840.298 32.9912 845.006 33.9706 849.042 35.9293C853.077 37.8881 856.216 40.7083 858.458 44.3898C860.7 48.0714 861.821 52.4845 861.821 57.6292C861.821 62.8211 860.664 67.1988 858.352 70.7624C856.063 74.3259 852.841 77.0163 848.688 78.8335C844.558 80.6506 839.732 81.5592 834.209 81.5592H815.518V66.2667H830.245C832.557 66.2667 834.528 65.9835 836.156 65.4171C837.808 64.8271 839.071 63.8949 839.944 62.6205C840.841 61.3461 841.289 59.6824 841.289 57.6292C841.289 55.5524 840.841 53.8651 839.944 52.5671C839.071 51.2455 837.808 50.2779 836.156 49.6643C834.528 49.0271 832.557 48.7085 830.245 48.7085H823.306V105.489H803.624ZM846.103 72.2138L864.228 105.489H842.847L825.147 72.2138H846.103Z" fill="#FAFAFA"/>
28
-<path d="M794.518 69.2402C794.518 77.3113 792.948 84.1198 789.81 89.6657C786.671 95.188 782.435 99.377 777.101 102.233C771.768 105.064 765.82 106.48 759.26 106.48C752.652 106.48 746.681 105.053 741.348 102.197C736.038 99.318 731.813 95.1172 728.675 89.5949C725.56 84.049 724.002 77.2641 724.002 69.2402C724.002 61.1691 725.56 54.3725 728.675 48.8501C731.813 43.3042 736.038 39.1153 741.348 36.2833C746.681 33.4278 752.652 32 759.26 32C765.82 32 771.768 33.4278 777.101 36.2833C782.435 39.1153 786.671 43.3042 789.81 48.8501C792.948 54.3725 794.518 61.1691 794.518 69.2402ZM774.269 69.2402C774.269 64.8979 773.691 61.2399 772.535 58.2664C771.402 55.2692 769.714 53.0037 767.472 51.4697C765.254 49.9121 762.517 49.1333 759.26 49.1333C756.003 49.1333 753.254 49.9121 751.012 51.4697C748.793 53.0037 747.106 55.2692 745.95 58.2664C744.817 61.2399 744.25 64.8979 744.25 69.2402C744.25 73.5826 744.817 77.2523 745.95 80.2495C747.106 83.223 748.793 85.4886 751.012 87.0461C753.254 88.5801 756.003 89.3471 759.26 89.3471C762.517 89.3471 765.254 88.5801 767.472 87.0461C769.714 85.4886 771.402 83.223 772.535 80.2495C773.691 77.2523 774.269 73.5826 774.269 69.2402Z" fill="#FAFAFA"/>
29
-<path d="M638.238 105.489L616.857 32.9912H638.804L648.716 77.5945H649.283L661.035 32.9912H678.31L690.063 77.7361H690.629L700.541 32.9912H722.489L701.107 105.489H682.275L669.956 64.9923H669.389L657.07 105.489H638.238Z" fill="#FAFAFA"/>
30
-</g>
31
-<path d="M649.525 133.05H642.505V164.911H638.725V133.05H631.704V129.81H649.525V133.05Z" fill="#FAFAFA"/>
32
-<path d="M622.242 155.191H626.022V158.161C626.022 160.501 625.302 162.355 623.862 163.723C622.422 165.055 620.19 165.721 617.165 165.721C614.141 165.721 611.891 165.055 610.415 163.723C608.939 162.355 608.201 160.501 608.201 158.161V136.56C608.201 134.22 608.921 132.384 610.361 131.052C611.801 129.684 614.033 129 617.057 129C620.082 129 622.332 129.684 623.808 131.052C625.284 132.384 626.022 134.22 626.022 136.56V141.69H622.242V136.56C622.242 135.192 621.81 134.13 620.946 133.374C620.117 132.618 618.821 132.24 617.057 132.24C615.329 132.24 614.051 132.618 613.223 133.374C612.395 134.13 611.981 135.192 611.981 136.56V158.161C611.981 159.493 612.395 160.555 613.223 161.347C614.087 162.103 615.401 162.481 617.165 162.481C618.893 162.481 620.171 162.103 621 161.347C621.828 160.555 622.242 159.493 622.242 158.161V155.191Z" fill="#FAFAFA"/>
33
-<path d="M594.456 164.911V129.81H598.236V164.911H594.456Z" fill="#FAFAFA"/>
34
-<path d="M551.898 164.911V129.81H566.749V133.05H555.678V145.471H564.589V148.711H555.678V164.911H551.898ZM574.309 129.81H578.089V161.671H587.971V164.911H574.309V129.81Z" fill="#FAFAFA"/>
35
-<path d="M521.839 164.911V129.81H525.457L537.337 156.811V129.81H541.117V164.911H537.499L525.619 137.91V164.911H521.839Z" fill="#FAFAFA"/>
36
-<path d="M481.068 155.191H484.848V158.161C484.848 160.501 484.128 162.355 482.688 163.723C481.248 165.055 479.015 165.721 475.991 165.721C472.967 165.721 470.717 165.055 469.241 163.723C467.765 162.355 467.027 160.501 467.027 158.161V136.56C467.027 134.22 467.747 132.384 469.187 131.052C470.627 129.684 472.859 129 475.883 129C478.907 129 481.158 129.684 482.634 131.052C484.11 132.384 484.848 134.22 484.848 136.56V141.69H481.068V136.56C481.068 135.192 480.635 134.13 479.771 133.374C478.943 132.618 477.647 132.24 475.883 132.24C474.155 132.24 472.877 132.618 472.049 133.374C471.221 134.13 470.807 135.192 470.807 136.56V158.161C470.807 159.493 471.221 160.555 472.049 161.347C472.913 162.103 474.227 162.481 475.991 162.481C477.719 162.481 478.997 162.103 479.825 161.347C480.653 160.555 481.068 159.493 481.068 158.161V155.191Z" fill="#FAFAFA"/>
37
-<path d="M435.94 164.911V152.329L427.03 129.81H431.026L437.83 147.901L444.635 129.81H448.631L439.72 152.383V164.911H435.94Z" fill="#FAFAFA"/>
38
-<path d="M411.97 129.81C414.85 129.81 416.992 130.494 418.396 131.862C419.836 133.194 420.556 135.03 420.556 137.37V140.61C420.556 143.67 419.278 145.633 416.722 146.497C419.638 147.289 421.096 149.287 421.096 152.491V157.351C421.096 159.691 420.394 161.545 418.99 162.913C417.586 164.245 415.426 164.911 412.51 164.911H403.816V129.81H411.97ZM416.776 137.37C416.776 136.038 416.362 134.994 415.534 134.238C414.742 133.446 413.554 133.05 411.97 133.05H407.596V145.201H411.97C413.59 145.201 414.796 144.768 415.588 143.904C416.38 143.04 416.776 141.942 416.776 140.61V137.37ZM417.316 152.491C417.316 151.159 416.902 150.061 416.074 149.197C415.282 148.333 414.094 147.901 412.51 147.901H407.596V161.671H412.51C414.13 161.671 415.336 161.293 416.128 160.537C416.92 159.745 417.316 158.683 417.316 157.351V152.491Z" fill="#FAFAFA"/>
39
-<path d="M364.106 164.911V129.81H372.8C375.68 129.81 377.822 130.494 379.226 131.862C380.666 133.194 381.386 135.03 381.386 137.37V157.351C381.386 159.691 380.684 161.545 379.28 162.913C377.876 164.245 375.716 164.911 372.8 164.911H364.106ZM377.606 137.37C377.606 136.038 377.192 134.994 376.364 134.238C375.572 133.446 374.384 133.05 372.8 133.05H367.886V161.671H372.8C374.42 161.671 375.626 161.293 376.418 160.537C377.21 159.745 377.606 158.683 377.606 157.351V137.37Z" fill="#FAFAFA"/>
40
-<path d="M350.335 155.191H354.115V158.161C354.115 160.501 353.395 162.355 351.955 163.723C350.515 165.055 348.283 165.721 345.259 165.721C342.235 165.721 339.985 165.055 338.509 163.723C337.033 162.355 336.295 160.501 336.295 158.161V136.56C336.295 134.22 337.015 132.384 338.455 131.052C339.895 129.684 342.127 129 345.151 129C348.175 129 350.425 129.684 351.901 131.052C353.377 132.384 354.115 134.22 354.115 136.56V148.441H340.075V158.161C340.075 159.493 340.489 160.555 341.317 161.347C342.181 162.103 343.495 162.481 345.259 162.481C346.987 162.481 348.265 162.103 349.093 161.347C349.921 160.555 350.335 159.493 350.335 158.161V155.191ZM340.075 145.2H350.335V136.56C350.335 135.192 349.903 134.13 349.039 133.374C348.211 132.618 346.915 132.24 345.151 132.24C343.423 132.24 342.145 132.618 341.317 133.374C340.489 134.13 340.075 135.192 340.075 136.56V145.2Z" fill="#FAFAFA"/>
41
-<path d="M315.483 164.911V129.81H317.103L318.669 133.806C319.173 132.438 319.857 131.304 320.721 130.404C321.585 129.468 322.737 129 324.177 129C326.265 129 327.849 129.594 328.929 130.782C330.045 131.934 330.603 133.68 330.603 136.02V139.53H326.985V135.804C326.985 134.76 326.751 133.914 326.283 133.266C325.815 132.582 325.059 132.24 324.015 132.24C323.331 132.24 322.611 132.51 321.855 133.05C321.135 133.59 320.523 134.454 320.019 135.642C319.515 136.794 319.263 138.324 319.263 140.232V164.911H315.483Z" fill="#FAFAFA"/>
42
-<path d="M301.713 155.191H305.493V158.161C305.493 160.501 304.773 162.355 303.333 163.723C301.893 165.055 299.661 165.721 296.636 165.721C293.612 165.721 291.362 165.055 289.886 163.723C288.41 162.355 287.672 160.501 287.672 158.161V136.56C287.672 134.22 288.392 132.384 289.832 131.052C291.272 129.684 293.504 129 296.528 129C299.553 129 301.803 129.684 303.279 131.052C304.755 132.384 305.493 134.22 305.493 136.56V148.441H291.452V158.161C291.452 159.493 291.866 160.555 292.694 161.347C293.558 162.103 294.872 162.481 296.636 162.481C298.365 162.481 299.643 162.103 300.471 161.347C301.299 160.555 301.713 159.493 301.713 158.161V155.191ZM291.452 145.2H301.713V136.56C301.713 135.192 301.281 134.13 300.417 133.374C299.589 132.618 298.293 132.24 296.528 132.24C294.8 132.24 293.522 132.618 292.694 133.374C291.866 134.13 291.452 135.192 291.452 136.56V145.2Z" fill="#FAFAFA"/>
43
-<path d="M260.684 129.81H264.464V158.161C264.464 159.493 264.878 160.555 265.706 161.347C266.534 162.103 267.776 162.481 269.432 162.481C271.124 162.481 272.384 162.103 273.212 161.347C274.04 160.555 274.454 159.493 274.454 158.161V129.81H278.235V158.161C278.235 160.501 277.479 162.355 275.966 163.723C274.49 165.055 272.312 165.721 269.432 165.721C267.776 165.721 266.39 165.433 265.274 164.857C264.158 164.281 263.258 163.525 262.574 162.589C261.926 163.561 261.026 164.335 259.874 164.911C258.722 165.451 257.336 165.721 255.716 165.721C252.836 165.721 250.64 165.055 249.128 163.723C247.652 162.355 246.914 160.501 246.914 158.161V129.81H250.694V158.161C250.694 159.493 251.108 160.555 251.936 161.347C252.8 162.103 254.06 162.481 255.716 162.481C257.408 162.481 258.65 162.103 259.442 161.347C260.27 160.555 260.684 159.493 260.684 158.161V129.81Z" fill="#FAFAFA"/>
44
-<path d="M237.476 158.161C237.476 160.501 236.72 162.355 235.208 163.723C233.732 165.055 231.446 165.721 228.35 165.721C225.254 165.721 222.932 165.055 221.383 163.723C219.871 162.355 219.115 160.501 219.115 158.161V136.56C219.115 134.22 219.853 132.384 221.329 131.052C222.842 129.684 225.146 129 228.242 129C231.338 129 233.642 129.684 235.154 131.052C236.702 132.384 237.476 134.22 237.476 136.56V158.161ZM233.696 136.56C233.696 135.192 233.246 134.13 232.346 133.374C231.446 132.618 230.078 132.24 228.242 132.24C226.442 132.24 225.092 132.618 224.192 133.374C223.328 134.13 222.896 135.192 222.896 136.56V158.161C222.896 159.493 223.346 160.555 224.246 161.347C225.146 162.103 226.514 162.481 228.35 162.481C230.15 162.481 231.482 162.103 232.346 161.347C233.246 160.555 233.696 159.493 233.696 158.161V136.56Z" fill="#FAFAFA"/>
45
-<path d="M194.56 164.911V129.81H202.714C205.594 129.81 207.736 130.494 209.14 131.862C210.58 133.194 211.3 135.03 211.3 137.37V146.551C211.3 148.891 210.598 150.745 209.194 152.113C207.79 153.445 205.63 154.111 202.714 154.111H198.34V164.911H194.56ZM207.52 137.37C207.52 136.038 207.106 134.994 206.278 134.238C205.486 133.446 204.298 133.05 202.714 133.05H198.34V150.871H202.714C204.334 150.871 205.54 150.493 206.332 149.737C207.124 148.945 207.52 147.883 207.52 146.551V137.37Z" fill="#FAFAFA"/>
46
-</g>
47
-<defs>
48
-<filter id="filter0_d_303_614" x="0" y="0" width="1033" height="213" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
49
-<feFlood flood-opacity="0" result="BackgroundImageFix"/>
50
-<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
51
-<feOffset dy="4"/>
52
-<feGaussianBlur stdDeviation="2"/>
53
-<feComposite in2="hardAlpha" operator="out"/>
54
-<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
55
-<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/>
56
-<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/>
57
-</filter>
58
-<filter id="filter1_d_303_614" x="190.56" y="32" width="812.841" height="85.1917" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
59
-<feFlood flood-opacity="0" result="BackgroundImageFix"/>
60
-<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
61
-<feOffset dy="4"/>
62
-<feGaussianBlur stdDeviation="2"/>
63
-<feComposite in2="hardAlpha" operator="out"/>
64
-<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
65
-<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/>
66
-<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/>
67
-</filter>
68
-<linearGradient id="paint0_linear_303_614" x1="48.4373" y1="83.8162" x2="111.888" y2="65.7367" gradientUnits="userSpaceOnUse">
69
-<stop stop-color="#DD4652"/>
70
-<stop offset="0.15" stop-color="#DD4854"/>
71
-<stop offset="0.23" stop-color="#DE4F5B"/>
72
-<stop offset="0.29" stop-color="#E15C67"/>
73
-<stop offset="0.34" stop-color="#E46F78"/>
74
-<stop offset="0.38" stop-color="#E9878F"/>
75
-<stop offset="0.42" stop-color="#EEA5AB"/>
76
-<stop offset="0.46" stop-color="#F5C8CC"/>
77
-<stop offset="0.49" stop-color="#FCF0F1"/>
78
-<stop offset="0.5" stop-color="#FEFDFD"/>
79
-<stop offset="0.52" stop-color="#F7D4D8"/>
80
-<stop offset="0.55" stop-color="#F1B0B7"/>
81
-<stop offset="0.58" stop-color="#EB8F9A"/>
82
-<stop offset="0.61" stop-color="#E67481"/>
83
-<stop offset="0.64" stop-color="#E25E6D"/>
84
-<stop offset="0.68" stop-color="#DF4D5E"/>
85
-<stop offset="0.73" stop-color="#DD4153"/>
86
-<stop offset="0.8" stop-color="#DC3A4D"/>
87
-<stop offset="1" stop-color="#DC394C"/>
88
-</linearGradient>
89
-<linearGradient id="paint1_linear_303_614" x1="107.569" y1="12.2053" x2="11.6611" y2="39.2106" gradientUnits="userSpaceOnUse">
90
-<stop stop-color="#8B3138" stop-opacity="0"/>
91
-<stop offset="0.65" stop-color="#DC394C"/>
92
-</linearGradient>
93
-<linearGradient id="paint2_linear_303_614" x1="107.569" y1="189.634" x2="11.6864" y2="162.629" gradientUnits="userSpaceOnUse">
94
-<stop stop-color="#8B3138" stop-opacity="0"/>
95
-<stop offset="0.65" stop-color="#DC394C"/>
96
-</linearGradient>
97
-<linearGradient id="paint3_linear_303_614" x1="30.5598" y1="131.944" x2="125.396" y2="131.944" gradientUnits="userSpaceOnUse">
98
-<stop stop-color="#8B3138" stop-opacity="0"/>
99
-<stop offset="0.65" stop-color="#DC394C"/>
100
-</linearGradient>
101
-<linearGradient id="paint4_linear_303_614" x1="30.5847" y1="69.8189" x2="125.396" y2="69.8189" gradientUnits="userSpaceOnUse">
102
-<stop stop-color="#8B3138" stop-opacity="0"/>
103
-<stop offset="0.65" stop-color="#DC394C"/>
104
-</linearGradient>
105
-<linearGradient id="paint5_linear_303_614" x1="-48.1491" y1="140.528" x2="123.546" y2="139.131" gradientUnits="userSpaceOnUse">
106
-<stop stop-color="#6A1921"/>
107
-<stop offset="1" stop-color="#DC394C"/>
108
-</linearGradient>
109
-</defs>
1
+<svg width="220" height="36" viewBox="0 0 220 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <!-- Ammonite spiral icon -->
3
+ <g transform="translate(4, 2)">
4
+ <circle cx="16" cy="16" r="14" stroke="#DC394C" stroke-width="2.5" fill="none"/>
5
+ <path d="M16 2C8.268 2 2 8.268 2 16" stroke="#DC394C" stroke-width="2.5" stroke-linecap="round" fill="none"/>
6
+ <path d="M16 7C11.029 7 7 11.029 7 16c0 4.971 4.029 9 9 9" stroke="#e8677a" stroke-width="2" stroke-linecap="round" fill="none"/>
7
+ <path d="M16 12c-2.209 0-4 1.791-4 4s1.791 4 4 4" stroke="#DC394C" stroke-width="1.5" stroke-linecap="round" fill="none"/>
8
+ <circle cx="16" cy="16" r="1.5" fill="#DC394C"/>
9
+ </g>
10
+ <!-- Wordmark -->
11
+ <text x="42" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#e5e5e5" letter-spacing="-0.5">fossil</text>
12
+ <text x="102" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#DC394C" letter-spacing="-0.5">repo</text>
11013
</svg>
11114
--- static/img/fossilrepo-logo-dark.svg
+++ static/img/fossilrepo-logo-dark.svg
@@ -1,110 +1,13 @@
1 <svg width="1033" height="213" viewBox="0 0 1033 213" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <g filter="url(#filter0_d_303_614)">
3 <rect x="4" width="1025" height="205" fill="#2B2D2C"/>
4 <path d="M491.56 167H500.064V163.116H495.59V132.884H500.138V129H491.56V167Z" fill="#DB394C"/>
5 <path d="M506.018 129V132.884H510.529V163.116H505.981V167H514.56V129H506.018Z" fill="#DB394C"/>
6 <path d="M505.093 139.932H501.026V155.788H505.093V139.932Z" fill="#DB394C"/>
7 <path d="M148.624 101.062C168.554 83.5327 166.323 44.969 144.11 30.1725C134.17 22.9805 119.767 19.0365 107.519 19.1396C101.434 19.6293 95.7028 22.4134 90.7074 26.048C84.6217 30.5592 79.7024 35.9209 72.9573 40.2774C65.1219 44.969 54.1422 50.4597 44.0754 49.2223C37.3303 47.3921 37.1782 40.8703 37.4825 34.9929C37.4825 34.9414 37.4825 34.8641 37.4825 34.8125C37.2796 34.8125 37.1275 34.8641 36.9246 34.8641C34.4903 34.8641 32.4871 34.1681 30.8896 33.0854C30.8135 33.7041 30.7374 34.3485 30.7374 34.9929C29.6724 46.129 34.2367 55.873 46.2814 56.3628C61.9776 56.5175 77.0145 46.7734 88.0956 36.9005C97.5793 27.9556 106.125 23.3156 119.006 26.7956C132.522 29.1929 144.871 36.1014 151.311 48.6294C160.034 65.849 155.216 92.9158 136.477 101.01C155.216 109.079 160.034 136.171 151.311 153.391C144.871 165.919 132.395 172.518 118.879 174.915C105.947 178.395 97.63 174.09 88.0956 165.12C77.0145 155.247 61.9523 145.503 46.2814 145.657C34.2367 146.199 29.6724 155.865 30.7374 167.053C30.7374 173.523 34.5917 179.117 40.0943 181.54C40.0182 180.354 40.0943 179.194 40.3986 178.112C40.7536 176.849 41.2861 175.766 41.9454 174.812C39.2575 173.265 37.4317 170.378 37.4317 167.053C37.1275 161.201 37.2542 154.654 44.0246 152.824C54.0661 151.586 65.0459 157.077 72.9066 161.768C79.6517 166.125 84.571 171.513 90.6567 175.998C95.6521 179.633 101.383 182.417 107.469 182.906C119.716 183.009 134.119 179.065 144.059 171.873C166.272 157.103 168.529 118.539 148.573 100.984L148.624 101.062Z" fill="#DC394C"/>
8 <path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="#DC394C"/>
9 <g opacity="0.2">
10 <path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="url(#paint0_linear_303_614)"/>
11 </g>
12 <path d="M37.7352 41.4374C37.4056 39.3236 37.4563 37.0294 37.5577 34.8382C37.5577 29.8889 41.5135 25.8676 46.3821 25.8676H79.5241C80.158 25.8676 80.7412 25.6356 81.223 25.2231C83.6827 23.1093 86.8523 20.7893 89.8445 19.0364H46.4074C37.8367 19.0364 30.8634 26.1253 30.8634 34.8382C30.483 38.7823 30.8381 42.5716 31.9538 45.8196C33.3991 43.7574 35.4277 42.1076 37.7606 41.4632L37.7352 41.4374Z" fill="url(#paint1_linear_303_614)"/>
13 <path d="M37.7352 160.402C37.4056 162.516 37.4563 164.81 37.5577 167.001C37.5577 171.951 41.5135 175.972 46.3821 175.972H79.5241C80.158 175.972 80.7412 176.204 81.223 176.617C83.6827 178.73 86.8523 181.05 89.8445 182.803H46.4074C37.8367 182.803 30.8634 175.714 30.8634 167.001C30.483 163.057 30.8381 159.268 31.9538 156.02C33.3991 158.082 35.4277 159.732 37.7606 160.376L37.7352 160.402Z" fill="url(#paint2_linear_303_614)"/>
14 <path d="M102.042 111.167C102.042 111.167 35.8849 111.167 30.5852 111.167V117.998H102.042C107.494 117.998 111.779 119.183 114.848 121.555C119.615 124.88 120.604 139.11 114.366 142.719C106.48 148.209 82.0859 144.781 72.8051 145.683C76.5833 147.436 80.3616 149.292 83.6327 151.483C84.5962 152.128 85.712 152.488 86.853 152.463C103.437 152.179 125.929 157.129 125.396 132.665C125.599 117.276 115.938 111.321 102.042 111.167Z" fill="url(#paint3_linear_303_614)"/>
15 <path d="M87.7147 49.2997C86.3454 49.2997 84.9761 49.6606 83.8097 50.4339C80.6146 52.5735 76.9125 54.3779 73.2103 56.0793C82.77 56.9815 106.226 53.5273 114.34 59.0437C120.578 62.6526 119.563 76.9078 114.822 80.2074C111.753 82.5531 107.468 83.7647 102.016 83.7647H30.5593V90.5958H102.016C115.912 90.4669 125.573 84.4865 125.37 69.0971C125.928 44.8401 104.324 49.5059 87.6893 49.2997H87.7147Z" fill="url(#paint4_linear_303_614)"/>
16 <path d="M121.263 126.427C120.198 126.608 119.133 126.814 118.068 127.072C119.539 132.614 118.702 140.218 114.391 142.744C106.505 148.235 82.1114 144.807 72.8306 145.709C76.6088 147.462 80.3871 149.318 83.6582 151.509C84.6217 152.153 85.7375 152.514 86.8785 152.489C103.462 152.205 125.954 157.154 125.422 132.691C125.447 130.062 125.168 127.768 124.661 125.654C123.596 126.04 122.48 126.298 121.288 126.427H121.263Z" fill="url(#paint5_linear_303_614)"/>
17 <g filter="url(#filter1_d_303_614)">
18 <rect x="195.06" y="32.5" width="803.841" height="76.1916" stroke="#2B2D2C" shape-rendering="crispEdges"/>
19 <path fill-rule="evenodd" clip-rule="evenodd" d="M549.696 108.274V32.9176H582.786C588.268 32.9176 593.264 33.9167 597.661 36.0512C602.078 38.1951 605.594 41.3351 608.098 45.4472C610.637 49.6163 611.813 54.4753 611.813 59.8467C611.813 65.2685 610.6 70.1409 607.941 74.2414C606.304 76.7879 604.248 78.9243 601.812 80.6522L616.857 108.274H587.652L576.157 86.6632V108.274H549.696ZM590.127 104.154H609.921L596.291 79.1301C597.57 78.5163 598.748 77.8075 599.824 77.0036C601.677 75.6192 603.23 73.9531 604.481 72.005C606.622 68.706 607.693 64.6532 607.693 59.8467C607.693 55.0838 606.655 50.9983 604.579 47.59C602.504 44.1817 599.598 41.5709 595.862 39.7576C592.126 37.9442 587.768 37.0375 582.786 37.0375H553.816V104.154H572.037V82.0004H578.343L590.127 104.154ZM572.037 67.843H578.46C580.601 67.843 582.426 67.5808 583.933 67.0565C585.463 66.5103 586.631 65.6473 587.44 64.4675C588.27 63.2877 588.685 61.7474 588.685 59.8467C588.685 57.9241 588.27 56.3619 587.44 55.1603C586.631 53.9368 585.463 53.0411 583.933 52.473C582.426 51.8831 580.601 51.5882 578.46 51.5882H572.037V67.843ZM576.157 55.7081V63.7231H578.46C580.277 63.7231 581.612 63.499 582.563 63.171C583.38 62.8765 583.79 62.5051 584.041 62.1388L584.056 62.1176L584.071 62.0965C584.278 61.8011 584.565 61.1655 584.565 59.8467C584.565 58.4992 584.275 57.8278 584.05 57.5022L584.026 57.467L584.002 57.4314C583.73 57.019 583.305 56.6347 582.499 56.3351L582.465 56.3227L582.432 56.3096C581.53 55.9566 580.244 55.7081 578.46 55.7081H576.157Z" fill="#FAFAFA"/>
20 <path fill-rule="evenodd" clip-rule="evenodd" d="M475.677 108.274V32.9176H532.288V55.8391H502.138V59.1351H529.797V82.0566H502.138V85.3525H532.157V108.274H475.677ZM498.018 89.4724V77.9367H525.677V63.2549H498.018V51.7193H528.168V37.0375H479.797V104.154H528.037V89.4724H498.018Z" fill="#FAFAFA"/>
21 <path fill-rule="evenodd" clip-rule="evenodd" d="M406.344 108.274V32.9176H432.805V85.3525H459.94V108.274H406.344ZM428.685 89.4724V37.0375H410.464V104.154H455.82V89.4724H428.685Z" fill="#FAFAFA"/>
22 <path fill-rule="evenodd" clip-rule="evenodd" d="M389.337 32.9176V108.274H362.876V32.9176H389.337ZM385.217 37.0375H366.996V104.154H385.217V37.0375Z" fill="#FAFAFA"/>
23 <path fill-rule="evenodd" clip-rule="evenodd" d="M341.181 91.5344L341.177 91.5409C337.907 97.2938 333.433 101.742 327.775 104.771L327.768 104.775L327.762 104.778C322.179 107.742 316.001 109.192 309.313 109.192C302.581 109.192 296.376 107.731 290.786 104.738L290.776 104.733L290.767 104.728C285.138 101.676 280.682 97.2211 277.416 91.4754L277.411 91.4663L277.406 91.4573C274.097 85.5658 272.552 78.5492 272.552 70.5958C272.552 62.6044 274.095 55.5718 277.41 49.6951L277.413 49.69C280.679 43.9181 285.143 39.4637 290.789 36.4517C296.378 33.46 302.582 32 309.313 32C316.004 32 322.185 33.4617 327.769 36.4501C333.435 39.4604 337.911 43.9139 341.18 49.6886C344.519 55.5668 346.074 62.602 346.074 70.5958C346.074 78.5928 344.518 85.6372 341.181 91.5344ZM325.83 40.0853C320.892 37.4417 315.387 36.1199 309.313 36.1199C303.196 36.1199 297.668 37.4417 292.731 40.0853C287.815 42.707 283.904 46.585 280.998 51.7193C278.114 56.8317 276.672 63.1239 276.672 70.5958C276.672 78.0241 278.114 84.3054 280.998 89.4396C283.904 94.552 287.815 98.4409 292.731 101.106C297.668 103.75 303.196 105.072 309.313 105.072C315.387 105.072 320.892 103.761 325.83 101.139C330.768 98.4956 334.689 94.6176 337.595 89.5052C340.501 84.3709 341.954 78.0678 341.954 70.5958C341.954 63.1239 340.501 56.8317 337.595 51.7193C334.689 46.585 330.768 42.707 325.83 40.0853ZM317.756 61.9115L317.749 61.8931C316.943 59.7622 315.851 58.4077 314.59 57.5445L314.569 57.5305L314.549 57.5161C313.319 56.6524 311.655 56.1013 309.313 56.1013C306.973 56.1013 305.289 56.6519 304.028 57.5279L304.02 57.533C302.77 58.3979 301.668 59.7627 300.838 61.9119C300.021 64.0587 299.538 66.9113 299.538 70.5958C299.538 74.2732 300.019 77.1389 300.838 79.3129C301.662 81.4232 302.759 82.7991 304.024 83.6938C305.285 84.5503 306.973 85.0903 309.313 85.0903C311.665 85.0903 313.337 84.545 314.569 83.6941C315.854 82.8 316.95 81.4286 317.753 79.3212L317.756 79.313L317.759 79.3048C318.595 77.1376 319.088 74.2759 319.088 70.5958C319.088 66.9141 318.594 64.0686 317.763 61.9298L317.756 61.9115ZM316.916 87.08C314.862 88.5002 312.328 89.2102 309.313 89.2102C306.298 89.2102 303.753 88.5002 301.677 87.08C299.624 85.6381 298.061 83.5407 296.991 80.7879C295.942 78.0132 295.418 74.6158 295.418 70.5958C295.418 66.5758 295.942 63.1894 296.991 60.4366C298.061 57.6619 299.624 55.5645 301.677 54.1444C303.753 52.7024 306.298 51.9814 309.313 51.9814C312.328 51.9814 314.862 52.7024 316.916 54.1444C318.992 55.5645 320.554 57.6619 321.602 60.4366C322.673 63.1894 323.208 66.5758 323.208 70.5958C323.208 74.6158 322.673 78.0132 321.602 80.7879C320.554 83.5407 318.992 85.6381 316.916 87.08Z" fill="#FAFAFA"/>
24 <path fill-rule="evenodd" clip-rule="evenodd" d="M194.56 108.274V32.9176H227.781C233.232 32.9176 238.075 33.6472 242.173 35.2702C246.242 36.8731 249.59 39.253 251.916 42.5486C254.255 45.8309 255.365 49.6469 255.365 53.8167C255.365 56.9112 254.675 59.8338 253.244 62.5031C252.104 64.6623 250.581 66.5265 248.711 68.0855C251.447 69.7483 253.658 71.9966 255.309 74.7798C257.146 77.8531 257.987 81.3596 257.987 85.1465C257.987 89.576 256.814 93.6497 254.409 97.2336C252.037 100.802 248.706 103.516 244.595 105.431C240.412 107.379 235.595 108.274 230.271 108.274H194.56ZM244.445 66.1032C244.662 65.967 244.875 65.8259 245.084 65.68C247.029 64.3036 248.536 62.5995 249.607 60.5676C250.699 58.5358 251.245 56.2855 251.245 53.8167C251.245 50.4084 250.35 47.448 248.558 44.9355C246.788 42.423 244.156 40.4785 240.66 39.1021C237.186 37.7257 232.893 37.0375 227.781 37.0375H198.679V104.154H230.271C235.143 104.154 239.338 103.335 242.856 101.696C246.373 100.058 249.082 97.8073 250.983 94.9453C252.906 92.0832 253.867 88.8169 253.867 85.1465C253.867 81.9786 253.168 79.2257 251.77 76.888C250.371 74.5284 248.493 72.6823 246.133 71.3496C245.682 71.0907 245.222 70.8546 244.752 70.6413C242.8 69.7553 240.683 69.2613 238.401 69.1594C238.357 69.1574 238.312 69.1556 238.268 69.1539V68.4984C238.276 68.4967 238.284 68.4951 238.293 68.4934C240.602 68.0248 242.653 67.2281 244.445 66.1032ZM228.809 80.0833L228.797 80.0783C228.133 79.7907 227.138 79.5659 225.683 79.5659H221.02V85.4836H225.421C228.065 85.4836 229.319 84.9764 229.832 84.6027C230.143 84.3635 230.477 83.9794 230.477 82.7869C230.477 81.8557 230.271 81.3515 230.077 81.0582C229.849 80.7117 229.491 80.3743 228.82 80.0881L228.809 80.0833ZM232.303 87.8993C230.774 89.0354 228.48 89.6035 225.421 89.6035H216.901V75.446H225.683C227.54 75.446 229.124 75.7301 230.435 76.2981C231.768 76.8662 232.795 77.6964 233.516 78.7888C234.237 79.8812 234.597 81.2139 234.597 82.7869C234.597 85.0373 233.833 86.7414 232.303 87.8993ZM228.114 58.8396L228.119 58.8327C228.228 58.6826 228.38 58.3975 228.38 57.7493C228.38 56.7949 228.117 56.5269 227.831 56.3166L227.815 56.305L227.799 56.2933C227.174 55.8244 226.219 55.4459 224.635 55.4459H221.02V60.0527H224.372C225.538 60.0527 226.399 59.869 227.035 59.6078C227.65 59.3551 227.943 59.0772 228.109 58.8465L228.114 58.8396ZM224.372 64.1726C225.967 64.1726 227.377 63.9213 228.6 63.4188C229.824 62.9163 230.774 62.1953 231.451 61.2559C232.15 60.2946 232.5 59.1257 232.5 57.7493C232.5 55.6737 231.757 54.0898 230.271 52.9974C228.786 51.8831 226.907 51.326 224.635 51.326H216.901V64.1726H224.372Z" fill="#FAFAFA"/>
25 <path d="M979.577 55.6468C979.388 53.2869 978.503 51.4461 976.922 50.1245C975.365 48.8029 972.993 48.1421 969.807 48.1421C967.777 48.1421 966.114 48.3899 964.816 48.8855C963.541 49.3575 962.597 50.0065 961.984 50.8325C961.37 51.6585 961.051 52.6025 961.028 53.6645C960.981 54.5377 961.134 55.3282 961.488 56.0362C961.866 56.7206 962.456 57.346 963.258 57.9124C964.06 58.4552 965.087 58.9508 966.338 59.3992C967.589 59.8476 969.075 60.2488 970.798 60.6028L976.745 61.8771C980.757 62.7267 984.191 63.8477 987.046 65.2401C989.902 66.6325 992.238 68.2726 994.056 70.1606C995.873 72.025 997.206 74.1253 998.056 76.4617C998.929 78.7981 999.377 81.3468 999.401 84.108C999.377 88.8751 998.185 92.9107 995.826 96.2146C993.466 99.5185 990.091 102.032 985.701 103.755C981.335 105.477 976.084 106.339 969.948 106.339C963.647 106.339 958.149 105.407 953.452 103.542C948.78 101.678 945.145 98.8106 942.549 94.9402C939.977 91.0463 938.679 86.0668 938.655 80.0017H957.346C957.464 82.22 958.019 84.0844 959.01 85.5948C960.001 87.1051 961.394 88.2497 963.187 89.0285C965.004 89.8073 967.164 90.1967 969.665 90.1967C971.766 90.1967 973.524 89.9371 974.94 89.4179C976.356 88.8987 977.43 88.1789 978.161 87.2585C978.893 86.3382 979.27 85.288 979.294 84.108C979.27 82.9988 978.905 82.0312 978.197 81.2052C977.512 80.3557 976.379 79.6005 974.798 78.9397C973.217 78.2553 971.081 77.6181 968.391 77.0281L961.169 75.4705C954.75 74.0781 949.688 71.7536 945.983 68.4968C942.302 65.2165 940.473 60.7444 940.496 55.0804C940.473 50.4785 941.7 46.4548 944.178 43.0092C946.679 39.5401 950.137 36.8379 954.55 34.9028C958.986 32.9676 964.072 32 969.807 32C975.66 32 980.722 32.9794 984.993 34.9382C989.265 36.8969 992.557 39.6581 994.87 43.2216C997.206 46.7616 998.386 50.9033 998.41 55.6468H979.577Z" fill="#FAFAFA"/>
26 <path d="M870.211 105.489V32.9912H889.893V62.3019H890.884L912.69 32.9912H935.629L911.132 65.2755L936.195 105.489H912.69L896.406 78.3025L889.893 86.7983V105.489H870.211Z" fill="#FAFAFA"/>
27 <path d="M803.624 105.489V32.9912H834.917C840.298 32.9912 845.006 33.9706 849.042 35.9293C853.077 37.8881 856.216 40.7083 858.458 44.3898C860.7 48.0714 861.821 52.4845 861.821 57.6292C861.821 62.8211 860.664 67.1988 858.352 70.7624C856.063 74.3259 852.841 77.0163 848.688 78.8335C844.558 80.6506 839.732 81.5592 834.209 81.5592H815.518V66.2667H830.245C832.557 66.2667 834.528 65.9835 836.156 65.4171C837.808 64.8271 839.071 63.8949 839.944 62.6205C840.841 61.3461 841.289 59.6824 841.289 57.6292C841.289 55.5524 840.841 53.8651 839.944 52.5671C839.071 51.2455 837.808 50.2779 836.156 49.6643C834.528 49.0271 832.557 48.7085 830.245 48.7085H823.306V105.489H803.624ZM846.103 72.2138L864.228 105.489H842.847L825.147 72.2138H846.103Z" fill="#FAFAFA"/>
28 <path d="M794.518 69.2402C794.518 77.3113 792.948 84.1198 789.81 89.6657C786.671 95.188 782.435 99.377 777.101 102.233C771.768 105.064 765.82 106.48 759.26 106.48C752.652 106.48 746.681 105.053 741.348 102.197C736.038 99.318 731.813 95.1172 728.675 89.5949C725.56 84.049 724.002 77.2641 724.002 69.2402C724.002 61.1691 725.56 54.3725 728.675 48.8501C731.813 43.3042 736.038 39.1153 741.348 36.2833C746.681 33.4278 752.652 32 759.26 32C765.82 32 771.768 33.4278 777.101 36.2833C782.435 39.1153 786.671 43.3042 789.81 48.8501C792.948 54.3725 794.518 61.1691 794.518 69.2402ZM774.269 69.2402C774.269 64.8979 773.691 61.2399 772.535 58.2664C771.402 55.2692 769.714 53.0037 767.472 51.4697C765.254 49.9121 762.517 49.1333 759.26 49.1333C756.003 49.1333 753.254 49.9121 751.012 51.4697C748.793 53.0037 747.106 55.2692 745.95 58.2664C744.817 61.2399 744.25 64.8979 744.25 69.2402C744.25 73.5826 744.817 77.2523 745.95 80.2495C747.106 83.223 748.793 85.4886 751.012 87.0461C753.254 88.5801 756.003 89.3471 759.26 89.3471C762.517 89.3471 765.254 88.5801 767.472 87.0461C769.714 85.4886 771.402 83.223 772.535 80.2495C773.691 77.2523 774.269 73.5826 774.269 69.2402Z" fill="#FAFAFA"/>
29 <path d="M638.238 105.489L616.857 32.9912H638.804L648.716 77.5945H649.283L661.035 32.9912H678.31L690.063 77.7361H690.629L700.541 32.9912H722.489L701.107 105.489H682.275L669.956 64.9923H669.389L657.07 105.489H638.238Z" fill="#FAFAFA"/>
30 </g>
31 <path d="M649.525 133.05H642.505V164.911H638.725V133.05H631.704V129.81H649.525V133.05Z" fill="#FAFAFA"/>
32 <path d="M622.242 155.191H626.022V158.161C626.022 160.501 625.302 162.355 623.862 163.723C622.422 165.055 620.19 165.721 617.165 165.721C614.141 165.721 611.891 165.055 610.415 163.723C608.939 162.355 608.201 160.501 608.201 158.161V136.56C608.201 134.22 608.921 132.384 610.361 131.052C611.801 129.684 614.033 129 617.057 129C620.082 129 622.332 129.684 623.808 131.052C625.284 132.384 626.022 134.22 626.022 136.56V141.69H622.242V136.56C622.242 135.192 621.81 134.13 620.946 133.374C620.117 132.618 618.821 132.24 617.057 132.24C615.329 132.24 614.051 132.618 613.223 133.374C612.395 134.13 611.981 135.192 611.981 136.56V158.161C611.981 159.493 612.395 160.555 613.223 161.347C614.087 162.103 615.401 162.481 617.165 162.481C618.893 162.481 620.171 162.103 621 161.347C621.828 160.555 622.242 159.493 622.242 158.161V155.191Z" fill="#FAFAFA"/>
33 <path d="M594.456 164.911V129.81H598.236V164.911H594.456Z" fill="#FAFAFA"/>
34 <path d="M551.898 164.911V129.81H566.749V133.05H555.678V145.471H564.589V148.711H555.678V164.911H551.898ZM574.309 129.81H578.089V161.671H587.971V164.911H574.309V129.81Z" fill="#FAFAFA"/>
35 <path d="M521.839 164.911V129.81H525.457L537.337 156.811V129.81H541.117V164.911H537.499L525.619 137.91V164.911H521.839Z" fill="#FAFAFA"/>
36 <path d="M481.068 155.191H484.848V158.161C484.848 160.501 484.128 162.355 482.688 163.723C481.248 165.055 479.015 165.721 475.991 165.721C472.967 165.721 470.717 165.055 469.241 163.723C467.765 162.355 467.027 160.501 467.027 158.161V136.56C467.027 134.22 467.747 132.384 469.187 131.052C470.627 129.684 472.859 129 475.883 129C478.907 129 481.158 129.684 482.634 131.052C484.11 132.384 484.848 134.22 484.848 136.56V141.69H481.068V136.56C481.068 135.192 480.635 134.13 479.771 133.374C478.943 132.618 477.647 132.24 475.883 132.24C474.155 132.24 472.877 132.618 472.049 133.374C471.221 134.13 470.807 135.192 470.807 136.56V158.161C470.807 159.493 471.221 160.555 472.049 161.347C472.913 162.103 474.227 162.481 475.991 162.481C477.719 162.481 478.997 162.103 479.825 161.347C480.653 160.555 481.068 159.493 481.068 158.161V155.191Z" fill="#FAFAFA"/>
37 <path d="M435.94 164.911V152.329L427.03 129.81H431.026L437.83 147.901L444.635 129.81H448.631L439.72 152.383V164.911H435.94Z" fill="#FAFAFA"/>
38 <path d="M411.97 129.81C414.85 129.81 416.992 130.494 418.396 131.862C419.836 133.194 420.556 135.03 420.556 137.37V140.61C420.556 143.67 419.278 145.633 416.722 146.497C419.638 147.289 421.096 149.287 421.096 152.491V157.351C421.096 159.691 420.394 161.545 418.99 162.913C417.586 164.245 415.426 164.911 412.51 164.911H403.816V129.81H411.97ZM416.776 137.37C416.776 136.038 416.362 134.994 415.534 134.238C414.742 133.446 413.554 133.05 411.97 133.05H407.596V145.201H411.97C413.59 145.201 414.796 144.768 415.588 143.904C416.38 143.04 416.776 141.942 416.776 140.61V137.37ZM417.316 152.491C417.316 151.159 416.902 150.061 416.074 149.197C415.282 148.333 414.094 147.901 412.51 147.901H407.596V161.671H412.51C414.13 161.671 415.336 161.293 416.128 160.537C416.92 159.745 417.316 158.683 417.316 157.351V152.491Z" fill="#FAFAFA"/>
39 <path d="M364.106 164.911V129.81H372.8C375.68 129.81 377.822 130.494 379.226 131.862C380.666 133.194 381.386 135.03 381.386 137.37V157.351C381.386 159.691 380.684 161.545 379.28 162.913C377.876 164.245 375.716 164.911 372.8 164.911H364.106ZM377.606 137.37C377.606 136.038 377.192 134.994 376.364 134.238C375.572 133.446 374.384 133.05 372.8 133.05H367.886V161.671H372.8C374.42 161.671 375.626 161.293 376.418 160.537C377.21 159.745 377.606 158.683 377.606 157.351V137.37Z" fill="#FAFAFA"/>
40 <path d="M350.335 155.191H354.115V158.161C354.115 160.501 353.395 162.355 351.955 163.723C350.515 165.055 348.283 165.721 345.259 165.721C342.235 165.721 339.985 165.055 338.509 163.723C337.033 162.355 336.295 160.501 336.295 158.161V136.56C336.295 134.22 337.015 132.384 338.455 131.052C339.895 129.684 342.127 129 345.151 129C348.175 129 350.425 129.684 351.901 131.052C353.377 132.384 354.115 134.22 354.115 136.56V148.441H340.075V158.161C340.075 159.493 340.489 160.555 341.317 161.347C342.181 162.103 343.495 162.481 345.259 162.481C346.987 162.481 348.265 162.103 349.093 161.347C349.921 160.555 350.335 159.493 350.335 158.161V155.191ZM340.075 145.2H350.335V136.56C350.335 135.192 349.903 134.13 349.039 133.374C348.211 132.618 346.915 132.24 345.151 132.24C343.423 132.24 342.145 132.618 341.317 133.374C340.489 134.13 340.075 135.192 340.075 136.56V145.2Z" fill="#FAFAFA"/>
41 <path d="M315.483 164.911V129.81H317.103L318.669 133.806C319.173 132.438 319.857 131.304 320.721 130.404C321.585 129.468 322.737 129 324.177 129C326.265 129 327.849 129.594 328.929 130.782C330.045 131.934 330.603 133.68 330.603 136.02V139.53H326.985V135.804C326.985 134.76 326.751 133.914 326.283 133.266C325.815 132.582 325.059 132.24 324.015 132.24C323.331 132.24 322.611 132.51 321.855 133.05C321.135 133.59 320.523 134.454 320.019 135.642C319.515 136.794 319.263 138.324 319.263 140.232V164.911H315.483Z" fill="#FAFAFA"/>
42 <path d="M301.713 155.191H305.493V158.161C305.493 160.501 304.773 162.355 303.333 163.723C301.893 165.055 299.661 165.721 296.636 165.721C293.612 165.721 291.362 165.055 289.886 163.723C288.41 162.355 287.672 160.501 287.672 158.161V136.56C287.672 134.22 288.392 132.384 289.832 131.052C291.272 129.684 293.504 129 296.528 129C299.553 129 301.803 129.684 303.279 131.052C304.755 132.384 305.493 134.22 305.493 136.56V148.441H291.452V158.161C291.452 159.493 291.866 160.555 292.694 161.347C293.558 162.103 294.872 162.481 296.636 162.481C298.365 162.481 299.643 162.103 300.471 161.347C301.299 160.555 301.713 159.493 301.713 158.161V155.191ZM291.452 145.2H301.713V136.56C301.713 135.192 301.281 134.13 300.417 133.374C299.589 132.618 298.293 132.24 296.528 132.24C294.8 132.24 293.522 132.618 292.694 133.374C291.866 134.13 291.452 135.192 291.452 136.56V145.2Z" fill="#FAFAFA"/>
43 <path d="M260.684 129.81H264.464V158.161C264.464 159.493 264.878 160.555 265.706 161.347C266.534 162.103 267.776 162.481 269.432 162.481C271.124 162.481 272.384 162.103 273.212 161.347C274.04 160.555 274.454 159.493 274.454 158.161V129.81H278.235V158.161C278.235 160.501 277.479 162.355 275.966 163.723C274.49 165.055 272.312 165.721 269.432 165.721C267.776 165.721 266.39 165.433 265.274 164.857C264.158 164.281 263.258 163.525 262.574 162.589C261.926 163.561 261.026 164.335 259.874 164.911C258.722 165.451 257.336 165.721 255.716 165.721C252.836 165.721 250.64 165.055 249.128 163.723C247.652 162.355 246.914 160.501 246.914 158.161V129.81H250.694V158.161C250.694 159.493 251.108 160.555 251.936 161.347C252.8 162.103 254.06 162.481 255.716 162.481C257.408 162.481 258.65 162.103 259.442 161.347C260.27 160.555 260.684 159.493 260.684 158.161V129.81Z" fill="#FAFAFA"/>
44 <path d="M237.476 158.161C237.476 160.501 236.72 162.355 235.208 163.723C233.732 165.055 231.446 165.721 228.35 165.721C225.254 165.721 222.932 165.055 221.383 163.723C219.871 162.355 219.115 160.501 219.115 158.161V136.56C219.115 134.22 219.853 132.384 221.329 131.052C222.842 129.684 225.146 129 228.242 129C231.338 129 233.642 129.684 235.154 131.052C236.702 132.384 237.476 134.22 237.476 136.56V158.161ZM233.696 136.56C233.696 135.192 233.246 134.13 232.346 133.374C231.446 132.618 230.078 132.24 228.242 132.24C226.442 132.24 225.092 132.618 224.192 133.374C223.328 134.13 222.896 135.192 222.896 136.56V158.161C222.896 159.493 223.346 160.555 224.246 161.347C225.146 162.103 226.514 162.481 228.35 162.481C230.15 162.481 231.482 162.103 232.346 161.347C233.246 160.555 233.696 159.493 233.696 158.161V136.56Z" fill="#FAFAFA"/>
45 <path d="M194.56 164.911V129.81H202.714C205.594 129.81 207.736 130.494 209.14 131.862C210.58 133.194 211.3 135.03 211.3 137.37V146.551C211.3 148.891 210.598 150.745 209.194 152.113C207.79 153.445 205.63 154.111 202.714 154.111H198.34V164.911H194.56ZM207.52 137.37C207.52 136.038 207.106 134.994 206.278 134.238C205.486 133.446 204.298 133.05 202.714 133.05H198.34V150.871H202.714C204.334 150.871 205.54 150.493 206.332 149.737C207.124 148.945 207.52 147.883 207.52 146.551V137.37Z" fill="#FAFAFA"/>
46 </g>
47 <defs>
48 <filter id="filter0_d_303_614" x="0" y="0" width="1033" height="213" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
49 <feFlood flood-opacity="0" result="BackgroundImageFix"/>
50 <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
51 <feOffset dy="4"/>
52 <feGaussianBlur stdDeviation="2"/>
53 <feComposite in2="hardAlpha" operator="out"/>
54 <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
55 <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/>
56 <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/>
57 </filter>
58 <filter id="filter1_d_303_614" x="190.56" y="32" width="812.841" height="85.1917" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
59 <feFlood flood-opacity="0" result="BackgroundImageFix"/>
60 <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
61 <feOffset dy="4"/>
62 <feGaussianBlur stdDeviation="2"/>
63 <feComposite in2="hardAlpha" operator="out"/>
64 <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
65 <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/>
66 <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/>
67 </filter>
68 <linearGradient id="paint0_linear_303_614" x1="48.4373" y1="83.8162" x2="111.888" y2="65.7367" gradientUnits="userSpaceOnUse">
69 <stop stop-color="#DD4652"/>
70 <stop offset="0.15" stop-color="#DD4854"/>
71 <stop offset="0.23" stop-color="#DE4F5B"/>
72 <stop offset="0.29" stop-color="#E15C67"/>
73 <stop offset="0.34" stop-color="#E46F78"/>
74 <stop offset="0.38" stop-color="#E9878F"/>
75 <stop offset="0.42" stop-color="#EEA5AB"/>
76 <stop offset="0.46" stop-color="#F5C8CC"/>
77 <stop offset="0.49" stop-color="#FCF0F1"/>
78 <stop offset="0.5" stop-color="#FEFDFD"/>
79 <stop offset="0.52" stop-color="#F7D4D8"/>
80 <stop offset="0.55" stop-color="#F1B0B7"/>
81 <stop offset="0.58" stop-color="#EB8F9A"/>
82 <stop offset="0.61" stop-color="#E67481"/>
83 <stop offset="0.64" stop-color="#E25E6D"/>
84 <stop offset="0.68" stop-color="#DF4D5E"/>
85 <stop offset="0.73" stop-color="#DD4153"/>
86 <stop offset="0.8" stop-color="#DC3A4D"/>
87 <stop offset="1" stop-color="#DC394C"/>
88 </linearGradient>
89 <linearGradient id="paint1_linear_303_614" x1="107.569" y1="12.2053" x2="11.6611" y2="39.2106" gradientUnits="userSpaceOnUse">
90 <stop stop-color="#8B3138" stop-opacity="0"/>
91 <stop offset="0.65" stop-color="#DC394C"/>
92 </linearGradient>
93 <linearGradient id="paint2_linear_303_614" x1="107.569" y1="189.634" x2="11.6864" y2="162.629" gradientUnits="userSpaceOnUse">
94 <stop stop-color="#8B3138" stop-opacity="0"/>
95 <stop offset="0.65" stop-color="#DC394C"/>
96 </linearGradient>
97 <linearGradient id="paint3_linear_303_614" x1="30.5598" y1="131.944" x2="125.396" y2="131.944" gradientUnits="userSpaceOnUse">
98 <stop stop-color="#8B3138" stop-opacity="0"/>
99 <stop offset="0.65" stop-color="#DC394C"/>
100 </linearGradient>
101 <linearGradient id="paint4_linear_303_614" x1="30.5847" y1="69.8189" x2="125.396" y2="69.8189" gradientUnits="userSpaceOnUse">
102 <stop stop-color="#8B3138" stop-opacity="0"/>
103 <stop offset="0.65" stop-color="#DC394C"/>
104 </linearGradient>
105 <linearGradient id="paint5_linear_303_614" x1="-48.1491" y1="140.528" x2="123.546" y2="139.131" gradientUnits="userSpaceOnUse">
106 <stop stop-color="#6A1921"/>
107 <stop offset="1" stop-color="#DC394C"/>
108 </linearGradient>
109 </defs>
110 </svg>
111
--- static/img/fossilrepo-logo-dark.svg
+++ static/img/fossilrepo-logo-dark.svg
@@ -1,110 +1,13 @@
1 <svg width="220" height="36" viewBox="0 0 220 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <!-- Ammonite spiral icon -->
3 <g transform="translate(4, 2)">
4 <circle cx="16" cy="16" r="14" stroke="#DC394C" stroke-width="2.5" fill="none"/>
5 <path d="M16 2C8.268 2 2 8.268 2 16" stroke="#DC394C" stroke-width="2.5" stroke-linecap="round" fill="none"/>
6 <path d="M16 7C11.029 7 7 11.029 7 16c0 4.971 4.029 9 9 9" stroke="#e8677a" stroke-width="2" stroke-linecap="round" fill="none"/>
7 <path d="M16 12c-2.209 0-4 1.791-4 4s1.791 4 4 4" stroke="#DC394C" stroke-width="1.5" stroke-linecap="round" fill="none"/>
8 <circle cx="16" cy="16" r="1.5" fill="#DC394C"/>
9 </g>
10 <!-- Wordmark -->
11 <text x="42" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#e5e5e5" letter-spacing="-0.5">fossil</text>
12 <text x="102" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#DC394C" letter-spacing="-0.5">repo</text>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13 </svg>
14
--- templates/admin/base_site.html
+++ templates/admin/base_site.html
@@ -33,11 +33,11 @@
3333
3434
{% block branding %}
3535
<h1 id="site-name">
3636
<div class="logo">
3737
<a role="listitem" class="item" href="/">
38
- <img src="{% static 'admin/img/logo-dark.svg' %}" alt="Fossilrepo" height="40">
38
+ <img src="{% static 'admin/img/logo-dark.png' %}" alt="Fossilrepo" height="40">
3939
</a>
4040
</div>
4141
</h1>
4242
{% endblock %}
4343
4444
--- templates/admin/base_site.html
+++ templates/admin/base_site.html
@@ -33,11 +33,11 @@
33
34 {% block branding %}
35 <h1 id="site-name">
36 <div class="logo">
37 <a role="listitem" class="item" href="/">
38 <img src="{% static 'admin/img/logo-dark.svg' %}" alt="Fossilrepo" height="40">
39 </a>
40 </div>
41 </h1>
42 {% endblock %}
43
44
--- templates/admin/base_site.html
+++ templates/admin/base_site.html
@@ -33,11 +33,11 @@
33
34 {% block branding %}
35 <h1 id="site-name">
36 <div class="logo">
37 <a role="listitem" class="item" href="/">
38 <img src="{% static 'admin/img/logo-dark.png' %}" alt="Fossilrepo" height="40">
39 </a>
40 </div>
41 </h1>
42 {% endblock %}
43
44
--- templates/auth1/login.html
+++ templates/auth1/login.html
@@ -4,11 +4,11 @@
44
55
{% block content %}
66
<div class="flex min-h-[80vh] items-center justify-center">
77
<div class="w-full max-w-sm space-y-8">
88
<div class="flex flex-col items-center">
9
- <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-12 w-auto mb-6">
9
+ <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6">
1010
<h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2>
1111
<p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p>
1212
</div>
1313
1414
{% if form.errors %}
1515
--- templates/auth1/login.html
+++ templates/auth1/login.html
@@ -4,11 +4,11 @@
4
5 {% block content %}
6 <div class="flex min-h-[80vh] items-center justify-center">
7 <div class="w-full max-w-sm space-y-8">
8 <div class="flex flex-col items-center">
9 <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-12 w-auto mb-6">
10 <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2>
11 <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p>
12 </div>
13
14 {% if form.errors %}
15
--- templates/auth1/login.html
+++ templates/auth1/login.html
@@ -4,11 +4,11 @@
4
5 {% block content %}
6 <div class="flex min-h-[80vh] items-center justify-center">
7 <div class="w-full max-w-sm space-y-8">
8 <div class="flex flex-col items-center">
9 <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6">
10 <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2>
11 <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p>
12 </div>
13
14 {% if form.errors %}
15
+103 -23
--- templates/base.html
+++ templates/base.html
@@ -1,15 +1,28 @@
11
<!DOCTYPE html>
2
-<html lang="en" class="h-full bg-gray-950">
2
+<html lang="en" class="h-full dark">
33
<head>
44
<meta charset="utf-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1">
66
<meta name="csrf-token" content="{{ csrf_token }}">
77
<title>{% block title %}Fossilrepo{% endblock %}</title>
8
- <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ // Apply theme before anything renders to prevent flash
10
+ (function() {
11
+ var theme = localStorage.getItem('theme');
12
+ if (theme === 'light') {
13
+ document.documentElement.classList.remove('dark');
14
+ } else {
15
+ document.documentElement.classList.add('dark');
16
+ }
17
+ })();
18
+ </script>
19
+ <script src="https://cdn.tailwindcss.com?plugins=typography"></script>
20
+ {% block extra_head %}{% endblock %}
921
<script>
1022
tailwind.config = {
23
+ darkMode: 'class',
1124
theme: {
1225
extend: {
1326
colors: {
1427
brand: {
1528
DEFAULT: '#DC394C',
@@ -25,15 +38,76 @@
2538
<style type="text/tailwindcss">
2639
@layer base {
2740
input[type="text"], input[type="number"], input[type="email"],
2841
input[type="password"], input[type="search"], input[type="url"],
2942
textarea, select {
30
- @apply bg-gray-800 border-gray-700 text-gray-100 rounded-md shadow-sm
43
+ @apply bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600
44
+ text-gray-900 dark:text-gray-100 rounded-md shadow-sm
3145
focus:border-brand focus:ring-brand sm:text-sm;
3246
}
3347
}
3448
</style>
49
+ <style>
50
+ /*
51
+ * Light mode — matches Django admin dark_theme.css palette
52
+ * Brand: #DC394C red, #8B3138 crimson, #2B2D2C charcoal
53
+ * Nav bar stays dark. Only main content area switches.
54
+ */
55
+ html:not(.dark) body { background-color: #f8f8f8; color: #1a1a1a; }
56
+ /* Surfaces */
57
+ html:not(.dark) main .bg-gray-950 { background-color: #f8f8f8 !important; }
58
+ html:not(.dark) main .bg-gray-900 { background-color: #eeeeee !important; }
59
+ html:not(.dark) main .bg-gray-800 { background-color: #ffffff !important; }
60
+ html:not(.dark) main .bg-gray-700 { background-color: #eeeeee !important; }
61
+ html:not(.dark) main .bg-gray-800\/50,
62
+ html:not(.dark) main .hover\:bg-gray-800\/50:hover { background-color: #eeeeee !important; }
63
+ html:not(.dark) main .hover\:bg-gray-800:hover { background-color: #eeeeee !important; }
64
+ html:not(.dark) main .hover\:bg-gray-700:hover { background-color: #e0e0e0 !important; }
65
+ html:not(.dark) main .hover\:bg-gray-700\/50:hover { background-color: #eeeeee !important; }
66
+ html:not(.dark) main .hover\:bg-gray-600:hover { background-color: #e0e0e0 !important; }
67
+ /* Text */
68
+ html:not(.dark) main .text-gray-100 { color: #1a1a1a !important; }
69
+ html:not(.dark) main .text-gray-200 { color: #1a1a1a !important; }
70
+ html:not(.dark) main .text-gray-300 { color: #666666 !important; }
71
+ html:not(.dark) main .text-gray-400 { color: #666666 !important; }
72
+ html:not(.dark) main .text-gray-500 { color: #a8aaa9 !important; }
73
+ html:not(.dark) main .hover\:text-white:hover { color: #000000 !important; }
74
+ html:not(.dark) main .hover\:text-gray-200:hover { color: #1a1a1a !important; }
75
+ /* Borders — matches --hairline-color / --border-color */
76
+ html:not(.dark) main .border-gray-700 { border-color: #e0e0e0 !important; }
77
+ html:not(.dark) main .border-gray-600 { border-color: #e0e0e0 !important; }
78
+ html:not(.dark) main .divide-gray-700 > :not([hidden]) ~ :not([hidden]) { border-color: #e0e0e0 !important; }
79
+ html:not(.dark) main .ring-gray-700 { --tw-ring-color: #e0e0e0 !important; }
80
+ html:not(.dark) main .ring-gray-600 { --tw-ring-color: #e0e0e0 !important; }
81
+ html:not(.dark) main .shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05) !important; }
82
+ /* Status badges — matches admin message colors */
83
+ html:not(.dark) main .bg-green-900\/50 { background-color: #d4edda !important; }
84
+ html:not(.dark) main .text-green-300 { color: #155724 !important; }
85
+ html:not(.dark) main .border-green-700 { border-color: #c3e6cb !important; }
86
+ html:not(.dark) main .bg-yellow-900\/50 { background-color: #fff3cd !important; }
87
+ html:not(.dark) main .text-yellow-300 { color: #856404 !important; }
88
+ html:not(.dark) main .text-yellow-400 { color: #856404 !important; }
89
+ html:not(.dark) main .bg-purple-900\/50 { background-color: #f3e8ff !important; }
90
+ html:not(.dark) main .text-purple-300 { color: #9333ea !important; }
91
+ html:not(.dark) main .bg-blue-900\/50 { background-color: #dbeafe !important; }
92
+ html:not(.dark) main .text-blue-300 { color: #2563eb !important; }
93
+ html:not(.dark) main .bg-red-900\/50 { background-color: #f8d7da !important; }
94
+ html:not(.dark) main .text-red-300 { color: #721c24 !important; }
95
+ html:not(.dark) main .border-red-700 { border-color: #f5c6cb !important; }
96
+ html:not(.dark) main .text-red-400 { color: #c0392b !important; }
97
+ html:not(.dark) main .hover\:text-red-300:hover { color: #721c24 !important; }
98
+ /* Mono text */
99
+ html:not(.dark) main .font-mono { color: #666666 !important; }
100
+ /* Prose — matches admin link/text colors */
101
+ html:not(.dark) main .prose-invert { --tw-prose-body: #1a1a1a; --tw-prose-headings: #000000; --tw-prose-links: #DC394C; --tw-prose-bold: #000000; --tw-prose-code: #1a1a1a; --tw-prose-th-borders: #e0e0e0; --tw-prose-td-borders: #e0e0e0; }
102
+ /* Brand links — matches admin --link-fg */
103
+ html:not(.dark) main .text-brand-light { color: #DC394C !important; }
104
+ html:not(.dark) main .hover\:text-brand:hover { color: #8B3138 !important; }
105
+ html:not(.dark) main .hover\:border-brand:hover { border-color: #DC394C !important; }
106
+ /* Selected/hover rows — matches admin --selected-bg */
107
+ html:not(.dark) main .group:hover { border-color: #DC394C !important; }
108
+ </style>
35109
<script src="https://unpkg.com/[email protected]"></script>
36110
<script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
37111
<script>
38112
document.body.addEventListener('htmx:configRequest', function(event) {
39113
var token = document.querySelector('meta[name="csrf-token"]');
@@ -40,32 +114,38 @@
40114
if (token) { event.detail.headers['X-CSRFToken'] = token.content; }
41115
});
42116
</script>
43117
</head>
44118
<body class="h-full bg-gray-950 text-gray-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
45
- <div class="min-h-full">
119
+ <div class="min-h-full flex flex-col">
46120
{% if user.is_authenticated %}
47121
{% include "includes/nav.html" %}
48122
{% endif %}
49123
50
- <main class="{% if user.is_authenticated %}py-6{% endif %}">
51
- <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
52
- {% if messages %}
53
- <div id="messages" class="mb-4 space-y-2">
54
- {% for message in messages %}
55
- <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
56
- x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}">
57
- <div class="flex justify-between">
58
- <p class="text-sm font-medium">{{ message }}</p>
59
- <button @click="show = false" class="ml-3 text-sm font-medium underline">&times;</button>
60
- </div>
61
- </div>
62
- {% endfor %}
63
- </div>
64
- {% endif %}
65
-
66
- {% block content %}{% endblock %}
67
- </div>
68
- </main>
124
+ <div class="flex flex-1 overflow-hidden">
125
+ {% if user.is_authenticated %}
126
+ {% include "includes/sidebar.html" %}
127
+ {% endif %}
128
+
129
+ <main class="flex-1 overflow-y-auto {% if user.is_authenticated %}py-6{% endif %}">
130
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
131
+ {% if messages %}
132
+ <div id="messages" class="mb-4 space-y-2">
133
+ {% for message in messages %}
134
+ <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
135
+ x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}">
136
+ <div class="flex justify-between">
137
+ <p class="text-sm font-medium">{{ message }}</p>
138
+ <button @click="show = false" class="ml-3 text-sm font-medium underline">&times;</button>
139
+ </div>
140
+ </div>
141
+ {% endfor %}
142
+ </div>
143
+ {% endif %}
144
+
145
+ {% block content %}{% endblock %}
146
+ </div>
147
+ </main>
148
+ </div>
69149
</div>
70150
</body>
71151
</html>
72152
--- templates/base.html
+++ templates/base.html
@@ -1,15 +1,28 @@
1 <!DOCTYPE html>
2 <html lang="en" class="h-full bg-gray-950">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <meta name="csrf-token" content="{{ csrf_token }}">
7 <title>{% block title %}Fossilrepo{% endblock %}</title>
8 <script src="https://cdn.tailwindcss.com"></script>
 
 
 
 
 
 
 
 
 
 
 
 
9 <script>
10 tailwind.config = {
 
11 theme: {
12 extend: {
13 colors: {
14 brand: {
15 DEFAULT: '#DC394C',
@@ -25,15 +38,76 @@
25 <style type="text/tailwindcss">
26 @layer base {
27 input[type="text"], input[type="number"], input[type="email"],
28 input[type="password"], input[type="search"], input[type="url"],
29 textarea, select {
30 @apply bg-gray-800 border-gray-700 text-gray-100 rounded-md shadow-sm
 
31 focus:border-brand focus:ring-brand sm:text-sm;
32 }
33 }
34 </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35 <script src="https://unpkg.com/[email protected]"></script>
36 <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
37 <script>
38 document.body.addEventListener('htmx:configRequest', function(event) {
39 var token = document.querySelector('meta[name="csrf-token"]');
@@ -40,32 +114,38 @@
40 if (token) { event.detail.headers['X-CSRFToken'] = token.content; }
41 });
42 </script>
43 </head>
44 <body class="h-full bg-gray-950 text-gray-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
45 <div class="min-h-full">
46 {% if user.is_authenticated %}
47 {% include "includes/nav.html" %}
48 {% endif %}
49
50 <main class="{% if user.is_authenticated %}py-6{% endif %}">
51 <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
52 {% if messages %}
53 <div id="messages" class="mb-4 space-y-2">
54 {% for message in messages %}
55 <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
56 x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}">
57 <div class="flex justify-between">
58 <p class="text-sm font-medium">{{ message }}</p>
59 <button @click="show = false" class="ml-3 text-sm font-medium underline">&times;</button>
60 </div>
61 </div>
62 {% endfor %}
63 </div>
64 {% endif %}
65
66 {% block content %}{% endblock %}
67 </div>
68 </main>
 
 
 
 
 
 
69 </div>
70 </body>
71 </html>
72
--- templates/base.html
+++ templates/base.html
@@ -1,15 +1,28 @@
1 <!DOCTYPE html>
2 <html lang="en" class="h-full dark">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <meta name="csrf-token" content="{{ csrf_token }}">
7 <title>{% block title %}Fossilrepo{% endblock %}</title>
8 <script>
9 // Apply theme before anything renders to prevent flash
10 (function() {
11 var theme = localStorage.getItem('theme');
12 if (theme === 'light') {
13 document.documentElement.classList.remove('dark');
14 } else {
15 document.documentElement.classList.add('dark');
16 }
17 })();
18 </script>
19 <script src="https://cdn.tailwindcss.com?plugins=typography"></script>
20 {% block extra_head %}{% endblock %}
21 <script>
22 tailwind.config = {
23 darkMode: 'class',
24 theme: {
25 extend: {
26 colors: {
27 brand: {
28 DEFAULT: '#DC394C',
@@ -25,15 +38,76 @@
38 <style type="text/tailwindcss">
39 @layer base {
40 input[type="text"], input[type="number"], input[type="email"],
41 input[type="password"], input[type="search"], input[type="url"],
42 textarea, select {
43 @apply bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600
44 text-gray-900 dark:text-gray-100 rounded-md shadow-sm
45 focus:border-brand focus:ring-brand sm:text-sm;
46 }
47 }
48 </style>
49 <style>
50 /*
51 * Light mode — matches Django admin dark_theme.css palette
52 * Brand: #DC394C red, #8B3138 crimson, #2B2D2C charcoal
53 * Nav bar stays dark. Only main content area switches.
54 */
55 html:not(.dark) body { background-color: #f8f8f8; color: #1a1a1a; }
56 /* Surfaces */
57 html:not(.dark) main .bg-gray-950 { background-color: #f8f8f8 !important; }
58 html:not(.dark) main .bg-gray-900 { background-color: #eeeeee !important; }
59 html:not(.dark) main .bg-gray-800 { background-color: #ffffff !important; }
60 html:not(.dark) main .bg-gray-700 { background-color: #eeeeee !important; }
61 html:not(.dark) main .bg-gray-800\/50,
62 html:not(.dark) main .hover\:bg-gray-800\/50:hover { background-color: #eeeeee !important; }
63 html:not(.dark) main .hover\:bg-gray-800:hover { background-color: #eeeeee !important; }
64 html:not(.dark) main .hover\:bg-gray-700:hover { background-color: #e0e0e0 !important; }
65 html:not(.dark) main .hover\:bg-gray-700\/50:hover { background-color: #eeeeee !important; }
66 html:not(.dark) main .hover\:bg-gray-600:hover { background-color: #e0e0e0 !important; }
67 /* Text */
68 html:not(.dark) main .text-gray-100 { color: #1a1a1a !important; }
69 html:not(.dark) main .text-gray-200 { color: #1a1a1a !important; }
70 html:not(.dark) main .text-gray-300 { color: #666666 !important; }
71 html:not(.dark) main .text-gray-400 { color: #666666 !important; }
72 html:not(.dark) main .text-gray-500 { color: #a8aaa9 !important; }
73 html:not(.dark) main .hover\:text-white:hover { color: #000000 !important; }
74 html:not(.dark) main .hover\:text-gray-200:hover { color: #1a1a1a !important; }
75 /* Borders — matches --hairline-color / --border-color */
76 html:not(.dark) main .border-gray-700 { border-color: #e0e0e0 !important; }
77 html:not(.dark) main .border-gray-600 { border-color: #e0e0e0 !important; }
78 html:not(.dark) main .divide-gray-700 > :not([hidden]) ~ :not([hidden]) { border-color: #e0e0e0 !important; }
79 html:not(.dark) main .ring-gray-700 { --tw-ring-color: #e0e0e0 !important; }
80 html:not(.dark) main .ring-gray-600 { --tw-ring-color: #e0e0e0 !important; }
81 html:not(.dark) main .shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05) !important; }
82 /* Status badges — matches admin message colors */
83 html:not(.dark) main .bg-green-900\/50 { background-color: #d4edda !important; }
84 html:not(.dark) main .text-green-300 { color: #155724 !important; }
85 html:not(.dark) main .border-green-700 { border-color: #c3e6cb !important; }
86 html:not(.dark) main .bg-yellow-900\/50 { background-color: #fff3cd !important; }
87 html:not(.dark) main .text-yellow-300 { color: #856404 !important; }
88 html:not(.dark) main .text-yellow-400 { color: #856404 !important; }
89 html:not(.dark) main .bg-purple-900\/50 { background-color: #f3e8ff !important; }
90 html:not(.dark) main .text-purple-300 { color: #9333ea !important; }
91 html:not(.dark) main .bg-blue-900\/50 { background-color: #dbeafe !important; }
92 html:not(.dark) main .text-blue-300 { color: #2563eb !important; }
93 html:not(.dark) main .bg-red-900\/50 { background-color: #f8d7da !important; }
94 html:not(.dark) main .text-red-300 { color: #721c24 !important; }
95 html:not(.dark) main .border-red-700 { border-color: #f5c6cb !important; }
96 html:not(.dark) main .text-red-400 { color: #c0392b !important; }
97 html:not(.dark) main .hover\:text-red-300:hover { color: #721c24 !important; }
98 /* Mono text */
99 html:not(.dark) main .font-mono { color: #666666 !important; }
100 /* Prose — matches admin link/text colors */
101 html:not(.dark) main .prose-invert { --tw-prose-body: #1a1a1a; --tw-prose-headings: #000000; --tw-prose-links: #DC394C; --tw-prose-bold: #000000; --tw-prose-code: #1a1a1a; --tw-prose-th-borders: #e0e0e0; --tw-prose-td-borders: #e0e0e0; }
102 /* Brand links — matches admin --link-fg */
103 html:not(.dark) main .text-brand-light { color: #DC394C !important; }
104 html:not(.dark) main .hover\:text-brand:hover { color: #8B3138 !important; }
105 html:not(.dark) main .hover\:border-brand:hover { border-color: #DC394C !important; }
106 /* Selected/hover rows — matches admin --selected-bg */
107 html:not(.dark) main .group:hover { border-color: #DC394C !important; }
108 </style>
109 <script src="https://unpkg.com/[email protected]"></script>
110 <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
111 <script>
112 document.body.addEventListener('htmx:configRequest', function(event) {
113 var token = document.querySelector('meta[name="csrf-token"]');
@@ -40,32 +114,38 @@
114 if (token) { event.detail.headers['X-CSRFToken'] = token.content; }
115 });
116 </script>
117 </head>
118 <body class="h-full bg-gray-950 text-gray-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
119 <div class="min-h-full flex flex-col">
120 {% if user.is_authenticated %}
121 {% include "includes/nav.html" %}
122 {% endif %}
123
124 <div class="flex flex-1 overflow-hidden">
125 {% if user.is_authenticated %}
126 {% include "includes/sidebar.html" %}
127 {% endif %}
128
129 <main class="flex-1 overflow-y-auto {% if user.is_authenticated %}py-6{% endif %}">
130 <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
131 {% if messages %}
132 <div id="messages" class="mb-4 space-y-2">
133 {% for message in messages %}
134 <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
135 x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}">
136 <div class="flex justify-between">
137 <p class="text-sm font-medium">{{ message }}</p>
138 <button @click="show = false" class="ml-3 text-sm font-medium underline">&times;</button>
139 </div>
140 </div>
141 {% endfor %}
142 </div>
143 {% endif %}
144
145 {% block content %}{% endblock %}
146 </div>
147 </main>
148 </div>
149 </div>
150 </body>
151 </html>
152
--- templates/dashboard.html
+++ templates/dashboard.html
@@ -6,14 +6,35 @@
66
<div class="md:flex md:items-center md:justify-between mb-8">
77
<h1 class="text-2xl font-bold text-gray-100">Dashboard</h1>
88
</div>
99
1010
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
11
- {% if perms.items.view_item %}
12
- <a href="{% url 'items:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
13
- <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Items</h3>
14
- <p class="mt-2 text-sm text-gray-400">Manage your item catalogue with full CRUD operations.</p>
11
+ {% if perms.projects.view_project %}
12
+ <a href="{% url 'projects:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
13
+ <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Projects</h3>
14
+ <p class="mt-2 text-sm text-gray-400">Manage projects and their team access controls.</p>
15
+ </a>
16
+ {% endif %}
17
+
18
+ {% if perms.organization.view_team %}
19
+ <a href="{% url 'organization:team_list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
20
+ <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Teams</h3>
21
+ <p class="mt-2 text-sm text-gray-400">Organize members into teams for project access.</p>
22
+ </a>
23
+ {% endif %}
24
+
25
+ {% if perms.pages.view_page %}
26
+ <a href="{% url 'pages:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
27
+ <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Docs</h3>
28
+ <p class="mt-2 text-sm text-gray-400">Org-wide documentation, guides, and runbooks.</p>
29
+ </a>
30
+ {% endif %}
31
+
32
+ {% if perms.organization.view_organization %}
33
+ <a href="{% url 'organization:settings' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
34
+ <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Settings</h3>
35
+ <p class="mt-2 text-sm text-gray-400">Organization settings, members, and configuration.</p>
1536
</a>
1637
{% endif %}
1738
1839
{% if user.is_staff %}
1940
<a href="{% url 'admin:index' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
@@ -22,12 +43,12 @@
2243
</a>
2344
{% endif %}
2445
2546
<div class="rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm">
2647
<div class="flex items-center gap-4 mb-3">
27
- <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-8 w-auto">
48
+ <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-8 w-auto">
2849
</div>
2950
<h3 class="text-lg font-semibold text-gray-100">Welcome, {{ user.get_full_name|default:user.username }}</h3>
30
- <p class="mt-2 text-sm text-gray-400">This is the Fossilrepo Django + HTMX template. Server-rendered with progressive enhancement via HTMX and Alpine.js.</p>
51
+ <p class="mt-2 text-sm text-gray-400">Self-hosted Fossil forge with Django + HTMX management layer.</p>
3152
</div>
3253
</div>
3354
{% endblock %}
3455
3556
ADDED templates/fossil/_project_nav.html
3657
ADDED templates/fossil/code_browser.html
3758
ADDED templates/fossil/code_file.html
3859
ADDED templates/fossil/forum_list.html
3960
ADDED templates/fossil/forum_thread.html
4061
ADDED templates/fossil/partials/file_tree.html
4162
ADDED templates/fossil/partials/ticket_table.html
4263
ADDED templates/fossil/partials/timeline_entries.html
4364
ADDED templates/fossil/ticket_detail.html
4465
ADDED templates/fossil/ticket_list.html
4566
ADDED templates/fossil/timeline.html
4667
ADDED templates/fossil/wiki_list.html
4768
ADDED templates/fossil/wiki_page.html
--- templates/dashboard.html
+++ templates/dashboard.html
@@ -6,14 +6,35 @@
6 <div class="md:flex md:items-center md:justify-between mb-8">
7 <h1 class="text-2xl font-bold text-gray-100">Dashboard</h1>
8 </div>
9
10 <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
11 {% if perms.items.view_item %}
12 <a href="{% url 'items:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
13 <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Items</h3>
14 <p class="mt-2 text-sm text-gray-400">Manage your item catalogue with full CRUD operations.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15 </a>
16 {% endif %}
17
18 {% if user.is_staff %}
19 <a href="{% url 'admin:index' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
@@ -22,12 +43,12 @@
22 </a>
23 {% endif %}
24
25 <div class="rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm">
26 <div class="flex items-center gap-4 mb-3">
27 <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-8 w-auto">
28 </div>
29 <h3 class="text-lg font-semibold text-gray-100">Welcome, {{ user.get_full_name|default:user.username }}</h3>
30 <p class="mt-2 text-sm text-gray-400">This is the Fossilrepo Django + HTMX template. Server-rendered with progressive enhancement via HTMX and Alpine.js.</p>
31 </div>
32 </div>
33 {% endblock %}
34
35 DDED templates/fossil/_project_nav.html
36 DDED templates/fossil/code_browser.html
37 DDED templates/fossil/code_file.html
38 DDED templates/fossil/forum_list.html
39 DDED templates/fossil/forum_thread.html
40 DDED templates/fossil/partials/file_tree.html
41 DDED templates/fossil/partials/ticket_table.html
42 DDED templates/fossil/partials/timeline_entries.html
43 DDED templates/fossil/ticket_detail.html
44 DDED templates/fossil/ticket_list.html
45 DDED templates/fossil/timeline.html
46 DDED templates/fossil/wiki_list.html
47 DDED templates/fossil/wiki_page.html
--- templates/dashboard.html
+++ templates/dashboard.html
@@ -6,14 +6,35 @@
6 <div class="md:flex md:items-center md:justify-between mb-8">
7 <h1 class="text-2xl font-bold text-gray-100">Dashboard</h1>
8 </div>
9
10 <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
11 {% if perms.projects.view_project %}
12 <a href="{% url 'projects:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
13 <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Projects</h3>
14 <p class="mt-2 text-sm text-gray-400">Manage projects and their team access controls.</p>
15 </a>
16 {% endif %}
17
18 {% if perms.organization.view_team %}
19 <a href="{% url 'organization:team_list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
20 <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Teams</h3>
21 <p class="mt-2 text-sm text-gray-400">Organize members into teams for project access.</p>
22 </a>
23 {% endif %}
24
25 {% if perms.pages.view_page %}
26 <a href="{% url 'pages:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
27 <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Docs</h3>
28 <p class="mt-2 text-sm text-gray-400">Org-wide documentation, guides, and runbooks.</p>
29 </a>
30 {% endif %}
31
32 {% if perms.organization.view_organization %}
33 <a href="{% url 'organization:settings' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
34 <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Settings</h3>
35 <p class="mt-2 text-sm text-gray-400">Organization settings, members, and configuration.</p>
36 </a>
37 {% endif %}
38
39 {% if user.is_staff %}
40 <a href="{% url 'admin:index' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all">
@@ -22,12 +43,12 @@
43 </a>
44 {% endif %}
45
46 <div class="rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm">
47 <div class="flex items-center gap-4 mb-3">
48 <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-8 w-auto">
49 </div>
50 <h3 class="text-lg font-semibold text-gray-100">Welcome, {{ user.get_full_name|default:user.username }}</h3>
51 <p class="mt-2 text-sm text-gray-400">Self-hosted Fossil forge with Django + HTMX management layer.</p>
52 </div>
53 </div>
54 {% endblock %}
55
56 DDED templates/fossil/_project_nav.html
57 DDED templates/fossil/code_browser.html
58 DDED templates/fossil/code_file.html
59 DDED templates/fossil/forum_list.html
60 DDED templates/fossil/forum_thread.html
61 DDED templates/fossil/partials/file_tree.html
62 DDED templates/fossil/partials/ticket_table.html
63 DDED templates/fossil/partials/timeline_entries.html
64 DDED templates/fossil/ticket_detail.html
65 DDED templates/fossil/ticket_list.html
66 DDED templates/fossil/timeline.html
67 DDED templates/fossil/wiki_list.html
68 DDED templates/fossil/wiki_page.html
--- a/templates/fossil/_project_nav.html
+++ b/templates/fossil/_project_nav.html
@@ -0,0 +1,26 @@
1
+<nav class="flex space-x-1 border-b border-gray-700 mb-6 overflow-x-auto">
2
+ <a href="{% url 'projects:detail' slug=project.slug %}"
3
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'overview' %}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 %}">
4
+ Overview
5
+ </a>
6
+ <a href="{% url 'fossil:code' slug=project.slug %}"
7
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'code' %}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 %}">
8
+ Code
9
+ </a>
10
+ <a href="{% url 'fossil:timeline' slug=project.slug %}"
11
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'timeline' %}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 %}">
12
+ Timeline
13
+ </a>
14
+ <a href="{% url 'fossil:tickets' slug=project.slug %}"
15
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'tickets' %}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 %}">
16
+ Tickets
17
+ </a>
18
+ <a href="{% url 'fossil:wiki' slug=project.slug %}"
19
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'wiki' %}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 %}">
20
+ Wiki
21
+ </a>
22
+ <a href="{% url 'fossil:forum' slug=project.slug %}"
23
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}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 %}">
24
+ Forum
25
+ </a>
26
+</nav>
--- a/templates/fossil/_project_nav.html
+++ b/templates/fossil/_project_nav.html
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/_project_nav.html
+++ b/templates/fossil/_project_nav.html
@@ -0,0 +1,26 @@
1 <nav class="flex space-x-1 border-b border-gray-700 mb-6 overflow-x-auto">
2 <a href="{% url 'projects:detail' slug=project.slug %}"
3 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'overview' %}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 %}">
4 Overview
5 </a>
6 <a href="{% url 'fossil:code' slug=project.slug %}"
7 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'code' %}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 %}">
8 Code
9 </a>
10 <a href="{% url 'fossil:timeline' slug=project.slug %}"
11 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'timeline' %}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 %}">
12 Timeline
13 </a>
14 <a href="{% url 'fossil:tickets' slug=project.slug %}"
15 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'tickets' %}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 %}">
16 Tickets
17 </a>
18 <a href="{% url 'fossil:wiki' slug=project.slug %}"
19 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'wiki' %}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 %}">
20 Wiki
21 </a>
22 <a href="{% url 'fossil:forum' slug=project.slug %}"
23 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}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 %}">
24 Forum
25 </a>
26 </nav>
--- a/templates/fossil/code_browser.html
+++ b/templates/fossil/code_browser.html
@@ -0,0 +1,34 @@
1
+rent_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %}
2
+
3
+{% block content %}
4
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
5
+{% include "fossil/_project_nav.html" %}
6
+
7
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow bordeLatest commit bar -->
8
+- Latest commit info -->
9
+ adcrumb + commit ba adata %}
10
+ <dijustify-between">
11
+ <!-- Breadcrumbs -->
12
+ <digap-3 min-w-0">
13
+ <span class="text-sm y-500">
14
+ <span cl200 flex-shrink-0oad humanize %}
15
+{% {% e }}</span>
16
+ {% else %}
17
+ <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a>
18
+ {% endif %}
19
+ {% endfor %}
20
+ </div>
21
+ {% if metadata %}
22
+ <div class="flex items-center gap-2 flex-shrink-0 text-xs text-gray-500">
23
+ <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
24
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
25
+ </svg>
26
+ {{ metadata.checkin_count }} commit{{ metadata.checkin_count|pluralize }}
27
+ </div>
28
+ {% endif %}
29
+ </div>
30
+
31
+ <!-- Latest commit info -->
32
+ {% if latest_commit %}
33
+ <div class="flex items-center gap-3 mt-2 text-xs text-gray-500">
34
+ <span class="font-medium text-gray-300">{{ latest_commit.user }}</span>
--- a/templates/fossil/code_browser.html
+++ b/templates/fossil/code_browser.html
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/code_browser.html
+++ b/templates/fossil/code_browser.html
@@ -0,0 +1,34 @@
1 rent_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %}
2
3 {% block content %}
4 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
5 {% include "fossil/_project_nav.html" %}
6
7 <div class="overflow-hidden rounded-lg bg-gray-800 shadow bordeLatest commit bar -->
8 - Latest commit info -->
9 adcrumb + commit ba adata %}
10 <dijustify-between">
11 <!-- Breadcrumbs -->
12 <digap-3 min-w-0">
13 <span class="text-sm y-500">
14 <span cl200 flex-shrink-0oad humanize %}
15 {% {% e }}</span>
16 {% else %}
17 <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a>
18 {% endif %}
19 {% endfor %}
20 </div>
21 {% if metadata %}
22 <div class="flex items-center gap-2 flex-shrink-0 text-xs text-gray-500">
23 <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
24 <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
25 </svg>
26 {{ metadata.checkin_count }} commit{{ metadata.checkin_count|pluralize }}
27 </div>
28 {% endif %}
29 </div>
30
31 <!-- Latest commit info -->
32 {% if latest_commit %}
33 <div class="flex items-center gap-3 mt-2 text-xs text-gray-500">
34 <span class="font-medium text-gray-300">{{ latest_commit.user }}</span>
--- a/templates/fossil/code_file.html
+++ b/templates/fossil/code_file.html
@@ -0,0 +1,33 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block extra_head %}
5
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
6
+<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
7
+{% endblock %}
8
+
9
+{% block content %}
10
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
11
+{% include "fossil/_project_nav.html" %}
12
+
13
+<div class="mb-4">
14
+ <a href="{% url 'fossil:code' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to files</a>
15
+</div>
16
+
17
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
18
+ <div class="px-4 py-3 border-b border-gray-700">
19
+ <span class="font-mono text-sm text-gray-300">{{ filepath }}</span>
20
+ </div>
21
+ <div class="overflow-x-auto">
22
+ {% if is_binary %}
23
+ <p class="p-4 text-sm text-gray-500">{{ content }}</p>
24
+ {% else %}
25
+ <pre class="p-0 m-0"><code class="language-{{ language }} text-sm leading-relaxed">{{ content }}</code></pre>
26
+ {% endif %}
27
+ </div>
28
+</div>
29
+
30
+{% if not is_binary %}
31
+<script>hljs.highlightAll();</script>
32
+{% endif %}
33
+{% endblock %}
--- a/templates/fossil/code_file.html
+++ b/templates/fossil/code_file.html
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/code_file.html
+++ b/templates/fossil/code_file.html
@@ -0,0 +1,33 @@
1 {% extends "base.html" %}
2 {% block title %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block extra_head %}
5 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
6 <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
7 {% endblock %}
8
9 {% block content %}
10 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
11 {% include "fossil/_project_nav.html" %}
12
13 <div class="mb-4">
14 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to files</a>
15 </div>
16
17 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
18 <div class="px-4 py-3 border-b border-gray-700">
19 <span class="font-mono text-sm text-gray-300">{{ filepath }}</span>
20 </div>
21 <div class="overflow-x-auto">
22 {% if is_binary %}
23 <p class="p-4 text-sm text-gray-500">{{ content }}</p>
24 {% else %}
25 <pre class="p-0 m-0"><code class="language-{{ language }} text-sm leading-relaxed">{{ content }}</code></pre>
26 {% endif %}
27 </div>
28 </div>
29
30 {% if not is_binary %}
31 <script>hljs.highlightAll();</script>
32 {% endif %}
33 {% endblock %}
--- a/templates/fossil/forum_list.html
+++ b/templates/fossil/forum_list.html
@@ -0,0 +1,20 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+d
8
+ </a>
9
+ {% endif %}
10
+ </div>2</div>
11
+
12
+<div id="forum-content">
13
+<div class="space-y-3">
14
+ {% for post in posts %}
15
+ <divpx-4 py-3">
16
+ends "base.html" %}
17
+{% load fossil_filters %}
18
+{% block title %}Forum — {{ project.name }} {% exteorder-brand focus:ring-b/path></s font-medium text-sm">
19
+ ="inline-flex item</a>
20
+ <div'fossil:forum_thre{{ post.user }} &middot; {{ post.timestamp|date:"N j, Y g:i a" }}
--- a/templates/fossil/forum_list.html
+++ b/templates/fossil/forum_list.html
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/forum_list.html
+++ b/templates/fossil/forum_list.html
@@ -0,0 +1,20 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 d
8 </a>
9 {% endif %}
10 </div>2</div>
11
12 <div id="forum-content">
13 <div class="space-y-3">
14 {% for post in posts %}
15 <divpx-4 py-3">
16 ends "base.html" %}
17 {% load fossil_filters %}
18 {% block title %}Forum — {{ project.name }} {% exteorder-brand focus:ring-b/path></s font-medium text-sm">
19 ="inline-flex item</a>
20 <div'fossil:forum_thre{{ post.user }} &middot; {{ post.timestamp|date:"N j, Y g:i a" }}
--- a/templates/fossil/forum_thread.html
+++ b/templates/fossil/forum_thread.html
@@ -0,0 +1,27 @@
1
+{% extends "base.html" %}
2
+{% block title %}Forum Thread — {{ 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:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to forum</a>
10
+</div>
11
+
12
+<div class="space-y-3">
13
+ {% for post in posts %}
14
+ <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4">
15
+ <div class="flex items-center justify-between mb-2">
16
+ <span class="text-sm font-medium text-gray-200">{{ post.user }}</span>
17
+ <span class="text-xs text-gray-500">{{ post.timestamp|date:"N j, Y g:i a" }}</span>
18
+ </div>
19
+ <div class="text-sm text-gray-300">
20
+ {{ post.title|default:post.body|default:"(empty)" }}
21
+ </div>
22
+ </div>
23
+ {% empty %}
24
+ <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p>
25
+ {% endfor %}
26
+</div>
27
+{% endblock %}
--- a/templates/fossil/forum_thread.html
+++ b/templates/fossil/forum_thread.html
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/forum_thread.html
+++ b/templates/fossil/forum_thread.html
@@ -0,0 +1,27 @@
1 {% extends "base.html" %}
2 {% block title %}Forum Thread — {{ 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:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to forum</a>
10 </div>
11
12 <div class="space-y-3">
13 {% for post in posts %}
14 <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4">
15 <div class="flex items-center justify-between mb-2">
16 <span class="text-sm font-medium text-gray-200">{{ post.user }}</span>
17 <span class="text-xs text-gray-500">{{ post.timestamp|date:"N j, Y g:i a" }}</span>
18 </div>
19 <div class="text-sm text-gray-300">
20 {{ post.title|default:post.body|default:"(empty)" }}
21 </div>
22 </div>
23 {% empty %}
24 <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p>
25 {% endfor %}
26 </div>
27 {% endblock %}
--- a/templates/fossil/partials/file_tree.html
+++ b/templates/fossil/partials/file_tree.html
@@ -0,0 +1 @@
1
+<div id="file-tree"ee" class="overflow-x-auto w-64' slug=project.slugrepository yet
--- a/templates/fossil/partials/file_tree.html
+++ b/templates/fossil/partials/file_tree.html
@@ -0,0 +1 @@
 
--- a/templates/fossil/partials/file_tree.html
+++ b/templates/fossil/partials/file_tree.html
@@ -0,0 +1 @@
1 <div id="file-tree"ee" class="overflow-x-auto w-64' slug=project.slugrepository yet
--- a/templates/fossil/partials/ticket_table.html
+++ b/templates/fossil/partials/ticket_table.html
@@ -0,0 +1,16 @@
1
+<div id="ticket-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <t6>
5
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wi6>
6
+ <th class="px-4 py-3 tStatus</th>
7
+ <th class="px-6>
8
+ <th class="px-4 py-3 tOwner</th>
9
+ <th class="px-6>
10
+ <th class="px-4 py-3 t00/70 bg-gray-800">
11
+ {% for ticket in tickets %}
12
+ <t50"-right text-xs font-medium upper <td class="px-4 py-3">
13
+ <a href="{% url 'fossil:ticket_detail' slug=project.slug tic6 py-4ug ticket_uuid=ticket.uuid %}"
14
+ 6 py-46 py-4 <a href="{% u{{ ticket.owner<div id="ticket-tablden<di <a href="{% u{{ ticket.created|date:"N j, Y {% for ticket in tickets %}
15
+ <t50"-right text-xs font-medium upper4" class="px-6-medium upper <td href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}"
16
+
--- a/templates/fossil/partials/ticket_table.html
+++ b/templates/fossil/partials/ticket_table.html
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/partials/ticket_table.html
+++ b/templates/fossil/partials/ticket_table.html
@@ -0,0 +1,16 @@
1 <div id="ticket-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <t6>
5 <th class="px-4 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wi6>
6 <th class="px-4 py-3 tStatus</th>
7 <th class="px-6>
8 <th class="px-4 py-3 tOwner</th>
9 <th class="px-6>
10 <th class="px-4 py-3 t00/70 bg-gray-800">
11 {% for ticket in tickets %}
12 <t50"-right text-xs font-medium upper <td class="px-4 py-3">
13 <a href="{% url 'fossil:ticket_detail' slug=project.slug tic6 py-4ug ticket_uuid=ticket.uuid %}"
14 6 py-46 py-4 <a href="{% u{{ ticket.owner<div id="ticket-tablden<di <a href="{% u{{ ticket.created|date:"N j, Y {% for ticket in tickets %}
15 <t50"-right text-xs font-medium upper4" class="px-6-medium upper <td href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}"
16
--- a/templates/fossil/partials/timeline_entries.html
+++ b/templates/fossil/partials/timeline_entries.html
@@ -0,0 +1,44 @@
1
+<style>
2
+ .dag-col { position: relative; flex-shrink: 0; }
3
+ .dag-node {
4
+ position: absolute; top: 50%; z-index: 2; border-radius: 50%;
5
+ transform: translate(-50%, -50%);
6
+ }
7
+ .dag-node-ci { width: 12px; height: 12px; background: #DC394C; border: 2px solid #e8677a; }
8
+ .dag-node-merge { width: 12px; height: 12px; background: #DC394C; border: 2px solid #e8677a; border-radius: 2px; transform: translate(-50%, -50%) rotate(45deg); }
9
+ .dag-node-w { width: 10px; height: 10px; background: #3b82f6; border: 2px solid #60a5fa; }
10
+ .dag-node-t { width: 10px; height: 10px; background: #eab308; border: 2px solid #facc15; }
11
+ .dag-node-f { width: 10px; height: 10px; background: #a855f7; border: 2px solid #c084fc; }
12
+ .dag-node-other { width: 10px; height: 10px; background: #6b7280; border: 2px solid #9ca3af; }
13
+ .dag-vline {
14
+ position: absolute; width: 2px; top: 0; bottom: 0;
15
+ transform: tra% endfor %}
16
+ {% else %}
17
+ <n -->
18
+ <div class="dag-col" style="width: {{ item.graph_width }}px;">
19
+ <!-- Vertical rail 500">{{ item.entry.for line in item.lines %}
20
+ <div class="dag-vline dag-vline-ci" style="left: {{ line.timesince }} ago{{ item.entry.event_ <!-- Node -->
21
+ <div class="dag-node {% if item.entry.is_merge %}dag-node-merge{% elif item.entry.event_type == 'ci' %}dag-node-ci{% elif item.entry.event_type == 'w' %}dag-node-w{% elif item.entry.event_type == 't' %}dag-node-t{% elif item.entry.event_type == 'f' %}dag-node-f{% else %}dag-node-other{% endif %}"
22
+ codee in item.lines font-monosition: relative; flex-shrink:<style>
23
+ .dag-col { positcodeendfor %}
24
+
25
+ <!endif %}
26
+ <dth: {{ item.connector.width }}px;
27
+ border-left: 2px solid rgba(220,57,76,0.35);
28
+ border-right: 2px solid rgba(220,57,76,0.35);"></div>
29
+ {% endif %}
30
+ </div>
31
+
32
+ <!-- Content -->
33
+ <div class="flex-1 py-1 min-w-0">
34
+ <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-2.5 hover:border-gray-600 transition-colors">
35
+ <div class="flex items-start justify-between gap-3">
36
+ <div class="flex-1 min-w-0">
37
+ {% if item.entry.event_type == "ci" %}
38
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=item.entry.uuid %}" class="text-sm text-gray-100 leading-snug hover:text-brand-light">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</a>
39
+ {% else %}
40
+ <p class="text-sm text-gray-100 leading-snug">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</p>
41
+ {% endif %}
42
+ <div class="mt-1 flex items-center gap-3 flex-wrap">
43
+ <span class="text-xs text-gray-400">{{ item.entry.user }}</span>
44
+ <span class="text-xs text-gray-600">{{ item.entry.timestamp|date:"Y-m-d H
--- a/templates/fossil/partials/timeline_entries.html
+++ b/templates/fossil/partials/timeline_entries.html
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/partials/timeline_entries.html
+++ b/templates/fossil/partials/timeline_entries.html
@@ -0,0 +1,44 @@
1 <style>
2 .dag-col { position: relative; flex-shrink: 0; }
3 .dag-node {
4 position: absolute; top: 50%; z-index: 2; border-radius: 50%;
5 transform: translate(-50%, -50%);
6 }
7 .dag-node-ci { width: 12px; height: 12px; background: #DC394C; border: 2px solid #e8677a; }
8 .dag-node-merge { width: 12px; height: 12px; background: #DC394C; border: 2px solid #e8677a; border-radius: 2px; transform: translate(-50%, -50%) rotate(45deg); }
9 .dag-node-w { width: 10px; height: 10px; background: #3b82f6; border: 2px solid #60a5fa; }
10 .dag-node-t { width: 10px; height: 10px; background: #eab308; border: 2px solid #facc15; }
11 .dag-node-f { width: 10px; height: 10px; background: #a855f7; border: 2px solid #c084fc; }
12 .dag-node-other { width: 10px; height: 10px; background: #6b7280; border: 2px solid #9ca3af; }
13 .dag-vline {
14 position: absolute; width: 2px; top: 0; bottom: 0;
15 transform: tra% endfor %}
16 {% else %}
17 <n -->
18 <div class="dag-col" style="width: {{ item.graph_width }}px;">
19 <!-- Vertical rail 500">{{ item.entry.for line in item.lines %}
20 <div class="dag-vline dag-vline-ci" style="left: {{ line.timesince }} ago{{ item.entry.event_ <!-- Node -->
21 <div class="dag-node {% if item.entry.is_merge %}dag-node-merge{% elif item.entry.event_type == 'ci' %}dag-node-ci{% elif item.entry.event_type == 'w' %}dag-node-w{% elif item.entry.event_type == 't' %}dag-node-t{% elif item.entry.event_type == 'f' %}dag-node-f{% else %}dag-node-other{% endif %}"
22 codee in item.lines font-monosition: relative; flex-shrink:<style>
23 .dag-col { positcodeendfor %}
24
25 <!endif %}
26 <dth: {{ item.connector.width }}px;
27 border-left: 2px solid rgba(220,57,76,0.35);
28 border-right: 2px solid rgba(220,57,76,0.35);"></div>
29 {% endif %}
30 </div>
31
32 <!-- Content -->
33 <div class="flex-1 py-1 min-w-0">
34 <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-2.5 hover:border-gray-600 transition-colors">
35 <div class="flex items-start justify-between gap-3">
36 <div class="flex-1 min-w-0">
37 {% if item.entry.event_type == "ci" %}
38 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=item.entry.uuid %}" class="text-sm text-gray-100 leading-snug hover:text-brand-light">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</a>
39 {% else %}
40 <p class="text-sm text-gray-100 leading-snug">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</p>
41 {% endif %}
42 <div class="mt-1 flex items-center gap-3 flex-wrap">
43 <span class="text-xs text-gray-400">{{ item.entry.user }}</span>
44 <span class="text-xs text-gray-600">{{ item.entry.timestamp|date:"Y-m-d H
--- a/templates/fossil/ticket_detail.html
+++ b/templates/fossil/ticket_detail.html
@@ -0,0 +1,48 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ ticket.title }} — {{ 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:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to tickets</a>
10
+</div>
11
+
12
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
13
+ <div class="px-6 py-5 border-b border-gray-700">
14
+ <h2 class="text-xl font-bold text-gray-100">{{ ticket.title|default:"(untitled)" }}</h2>
15
+ <p class="mt-1 text-sm text-gray-400">
16
+ <code class="font-mono text-xs text-gray-500">{{ ticket.uuid|truncatechars:16 }}</code>
17
+ </p>
18
+ </div>
19
+ <div class="px-6 py-5">
20
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-3">
21
+ <div>
22
+ <dt class="text-sm font-medium text-gray-400">Status</dt>
23
+ <dd class="mt-1 text-sm text-gray-100">{{ ticket.status|default:"—" }}</dd>
24
+ </div>
25
+ <div>
26
+ <dt class="text-sm font-medium text-gray-400">Type</dt>
27
+ <dd class="mt-1 text-sm text-gray-100">{{ ticket.type|default:"—" }}</dd>
28
+ </div>
29
+ <div>
30
+ <dt class="text-sm font-medium text-gray-400">Owner</dt>
31
+ <dd class="mt-1 text-sm text-gray-100">{{ ticket.owner|default:"—" }}</dd>
32
+ </div>
33
+ <div>
34
+ <dt class="text-sm font-medium text-gray-400">Priority</dt>
35
+ <dd class="mt-1 text-sm text-gray-100">{{ ticket.priority|default:"—" }}</dd>
36
+ </div>
37
+ <div>
38
+ <dt class="text-sm font-medium text-gray-400">Subsystem</dt>
39
+ <dd class="mt-1 text-sm text-gray-100">{{ ticket.subsystem|default:"—" }}</dd>
40
+ </div>
41
+ <div>
42
+ <dt class="text-sm font-medium text-gray-400">Created</dt>
43
+ <dd class="mt-1 text-sm text-gray-100">{{ ticket.created|date:"N j, Y g:i a" }}</dd>
44
+ </div>
45
+ </dl>
46
+ </div>
47
+</div>
48
+{% endblock %}
--- a/templates/fossil/ticket_detail.html
+++ b/templates/fossil/ticket_detail.html
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_detail.html
+++ b/templates/fossil/ticket_detail.html
@@ -0,0 +1,48 @@
1 {% extends "base.html" %}
2 {% block title %}{{ ticket.title }} — {{ 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:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to tickets</a>
10 </div>
11
12 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
13 <div class="px-6 py-5 border-b border-gray-700">
14 <h2 class="text-xl font-bold text-gray-100">{{ ticket.title|default:"(untitled)" }}</h2>
15 <p class="mt-1 text-sm text-gray-400">
16 <code class="font-mono text-xs text-gray-500">{{ ticket.uuid|truncatechars:16 }}</code>
17 </p>
18 </div>
19 <div class="px-6 py-5">
20 <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-3">
21 <div>
22 <dt class="text-sm font-medium text-gray-400">Status</dt>
23 <dd class="mt-1 text-sm text-gray-100">{{ ticket.status|default:"—" }}</dd>
24 </div>
25 <div>
26 <dt class="text-sm font-medium text-gray-400">Type</dt>
27 <dd class="mt-1 text-sm text-gray-100">{{ ticket.type|default:"—" }}</dd>
28 </div>
29 <div>
30 <dt class="text-sm font-medium text-gray-400">Owner</dt>
31 <dd class="mt-1 text-sm text-gray-100">{{ ticket.owner|default:"—" }}</dd>
32 </div>
33 <div>
34 <dt class="text-sm font-medium text-gray-400">Priority</dt>
35 <dd class="mt-1 text-sm text-gray-100">{{ ticket.priority|default:"—" }}</dd>
36 </div>
37 <div>
38 <dt class="text-sm font-medium text-gray-400">Subsystem</dt>
39 <dd class="mt-1 text-sm text-gray-100">{{ ticket.subsystem|default:"—" }}</dd>
40 </div>
41 <div>
42 <dt class="text-sm font-medium text-gray-400">Created</dt>
43 <dd class="mt-1 text-sm text-gray-100">{{ ticket.created|date:"N j, Y g:i a" }}</dd>
44 </div>
45 </dl>
46 </div>
47 </div>
48 {% endblock %}
--- a/templates/fossil/ticket_list.html
+++ b/templates/fossil/ticket_list.html
@@ -0,0 +1,19 @@
1
+{% extends "base.html" %}
2
+{% block title %}Tickets — {{ 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/_projectmb-4">
7
+ <input type="search"
8
+ t type="search"
9
+ value="{{ search }}"
10
+ ="{{ search }}class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
11
+ hx-get="{% url 'fossil:ti"
12
+ hx-trigger="input changed delay:300ms, search"
13
+ hx-target="#ticket-table"
14
+ "#ticket-table"
15
+ hxhx-push-url="true" /-push-url="true" />
16
+ </div>
17
+</div>
18
+
19
+{% include "fossil/partials/ticket_tab
--- a/templates/fossil/ticket_list.html
+++ b/templates/fossil/ticket_list.html
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_list.html
+++ b/templates/fossil/ticket_list.html
@@ -0,0 +1,19 @@
1 {% extends "base.html" %}
2 {% block title %}Tickets — {{ 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/_projectmb-4">
7 <input type="search"
8 t type="search"
9 value="{{ search }}"
10 ="{{ search }}class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
11 hx-get="{% url 'fossil:ti"
12 hx-trigger="input changed delay:300ms, search"
13 hx-target="#ticket-table"
14 "#ticket-table"
15 hxhx-push-url="true" /-push-url="true" />
16 </div>
17 </div>
18
19 {% include "fossil/partials/ticket_tab
--- a/templates/fossil/timeline.html
+++ b/templates/fossil/timeline.html
@@ -0,0 +1,30 @@
1
+{% extends "base.html" %}
2
+{% block title %}Timeline — {{ 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 gap-2 mb-4 text-xs text-gray-500">
9
+ <span>Filter:</span>
10
+ <a href="{% url 'fossil:timeline' slug=project.slug %}"
11
+ class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
12
+ <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci"
13
+ class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a>
14
+ <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w"
15
+ class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a>
16
+ <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t"
17
+ class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a>
18
+</div>
19
+
20
+{% include "fossil/partials/timeline_entries.html" %}
21
+
22
+{% if entries|length == 50 %}
23
+<div class="mt-4 text-center">
24
+ <a href="{% url 'fossil:timeline' slug=project.slug %}?page={{ page|add:1 }}{% if event_type %}&type={{ event_type }}{% endif %}"
25
+ class="inline-flex items-center rounded-md bg-gray-800 px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700">
26
+ Load more
27
+ </a>
28
+</div>
29
+{% endif %}
30
+{% endblock %}
--- a/templates/fossil/timeline.html
+++ b/templates/fossil/timeline.html
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/timeline.html
+++ b/templates/fossil/timeline.html
@@ -0,0 +1,30 @@
1 {% extends "base.html" %}
2 {% block title %}Timeline — {{ 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 gap-2 mb-4 text-xs text-gray-500">
9 <span>Filter:</span>
10 <a href="{% url 'fossil:timeline' slug=project.slug %}"
11 class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
12 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci"
13 class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a>
14 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w"
15 class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a>
16 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t"
17 class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a>
18 </div>
19
20 {% include "fossil/partials/timeline_entries.html" %}
21
22 {% if entries|length == 50 %}
23 <div class="mt-4 text-center">
24 <a href="{% url 'fossil:timeline' slug=project.slug %}?page={{ page|add:1 }}{% if event_type %}&type={{ event_type }}{% endif %}"
25 class="inline-flex items-center rounded-md bg-gray-800 px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700">
26 Load more
27 </a>
28 </div>
29 {% endif %}
30 {% endblock %}
--- a/templates/fossil/wiki_list.html
+++ b/templates/fossil/wiki_list.html
@@ -0,0 +1,6 @@
1
+{% extends "base.html" %}
2
+{% blotext-gray-400 hx-swap="innerHTML{ p.name }}
3
+ged dela <h2 class="text-lg font-semibold text-gray-100">{{ home_page.name }}</h2>
4
+ </div>
5
+ <div class="px-6 py-6">
6
+ <div
--- a/templates/fossil/wiki_list.html
+++ b/templates/fossil/wiki_list.html
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/templates/fossil/wiki_list.html
+++ b/templates/fossil/wiki_list.html
@@ -0,0 +1,6 @@
1 {% extends "base.html" %}
2 {% blotext-gray-400 hx-swap="innerHTML{ p.name }}
3 ged dela <h2 class="text-lg font-semibold text-gray-100">{{ home_page.name }}</h2>
4 </div>
5 <div class="px-6 py-6">
6 <div
--- a/templates/fossil/wiki_page.html
+++ b/templates/fossil/wiki_page.html
@@ -0,0 +1,35 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}{{ page.name }} — Wiki — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+{% include "fossil/_project_nav.html" %}
8
+
9
+<div class="flex gap-6">
10
+ <!-- Main wiki content -->
11
+ <div class="flex-1 min-w-0">
12
+ <div class="overflow-hidden roed-lg bg-gray-800 shadow-sm border border-gray-700">
13
+ <div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
14
+ <h2 class="text-lg font-semibold text-gray-100">{{ -center gap-3">
15
+ <span class="text-xs text-gray-500">{{ page.last_modi</div>
16
+ <div class="px-6 py-6">
17
+ <div class="prose prose-invert prose-gray max-w-none">
18
+ {{ content_html }}
19
+ </div>
20
+ </div>
21
+ </div>
22
+ </div>
23
+
24
+ <!-- Right sidebar: wiki page nav -->
25
+ <aside class="hidden lg:block w-52 flex-shrink-0">
26
+ <div class="sticky top-6">
27
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-3">Wiki Pages</h3>
28
+ <nav class="space-y-0.5">
29
+ {% for p in all_pages %}
30
+ <a href="{% url 'fossil:wiki_page' slug=project.slug page_name=p.name %}"
31
+ class="block rounded-md px-3 py-1.5 text-sm {% if p.name == page.name %}bg-gray-800 text-brand-light font-medium border border-gray-700{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
32
+ {{ p.name }}
33
+ </a>
34
+ {% endfor %}
35
+
--- a/templates/fossil/wiki_page.html
+++ b/templates/fossil/wiki_page.html
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/wiki_page.html
+++ b/templates/fossil/wiki_page.html
@@ -0,0 +1,35 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}{{ page.name }} — Wiki — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="flex gap-6">
10 <!-- Main wiki content -->
11 <div class="flex-1 min-w-0">
12 <div class="overflow-hidden roed-lg bg-gray-800 shadow-sm border border-gray-700">
13 <div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
14 <h2 class="text-lg font-semibold text-gray-100">{{ -center gap-3">
15 <span class="text-xs text-gray-500">{{ page.last_modi</div>
16 <div class="px-6 py-6">
17 <div class="prose prose-invert prose-gray max-w-none">
18 {{ content_html }}
19 </div>
20 </div>
21 </div>
22 </div>
23
24 <!-- Right sidebar: wiki page nav -->
25 <aside class="hidden lg:block w-52 flex-shrink-0">
26 <div class="sticky top-6">
27 <h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-3">Wiki Pages</h3>
28 <nav class="space-y-0.5">
29 {% for p in all_pages %}
30 <a href="{% url 'fossil:wiki_page' slug=project.slug page_name=p.name %}"
31 class="block rounded-md px-3 py-1.5 text-sm {% if p.name == page.name %}bg-gray-800 text-brand-light font-medium border border-gray-700{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
32 {{ p.name }}
33 </a>
34 {% endfor %}
35
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -1,63 +1,37 @@
11
{% load static %}
2
-<nav class="bg-gray-900 border-b border-gray-700" x-data="{ mobileOpen: false }">
3
- <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
4
- <div class="flex h-16 items-center justify-between">
5
- <div class="flex items-center">
6
- <a href="{% url 'dashboard' %}" class="flex-shrink-0">
7
- <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-8 w-auto">
8
- </a>
9
- <div class="hidden md:block ml-10">
10
- <div class="flex items-baseline space-x-4">
11
- <a href="{% url 'dashboard' %}"
12
- class="{% if request.path == '/dashboard/' %}bg-brand text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium">
13
- Dashboard
14
- </a>
15
- {% if perms.items.view_item %}
16
- <a href="{% url 'items:list' %}"
17
- class="{% if '/items/' in request.path %}bg-brand text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium">
18
- Items
19
- </a>
20
- {% endif %}
21
- {% if user.is_staff %}
22
- <a href="{% url 'admin:index' %}"
23
- class="text-gray-400 hover:bg-gray-800 hover:text-white rounded-md px-3 py-2 text-sm font-medium">
24
- Admin
25
- </a>
26
- {% endif %}
27
- </div>
28
- </div>
29
- </div>
30
- <div class="hidden md:block">
31
- <div class="ml-4 flex items-center md:ml-6" x-data="{ open: false }">
32
- <div class="relative">
33
- <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white">
34
- {{ user.get_full_name|default:user.username }}
35
- <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
36
- </button>
37
- <div x-show="open" @click.outside="open = false" x-transition
38
- class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
39
- <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form>
40
- </div>
41
- </div>
42
- </div>
43
- </div>
44
- <div class="md:hidden">
45
- <button @click="mobileOpen = !mobileOpen" class="text-gray-400 hover:text-white">
46
- <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
47
- <path x-show="!mobileOpen" stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
48
- <path x-show="mobileOpen" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
49
- </svg>
50
- </button>
51
- </div>
52
- </div>
53
- </div>
54
- <div x-show="mobileOpen" class="md:hidden">
55
- <div class="space-y-1 px-2 pb-3 pt-2">
56
- <a href="{% url 'dashboard' %}" class="block rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Dashboard</a>
57
- {% if perms.items.view_item %}
58
- <a href="{% url 'items:list' %}" class="block rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Items</a>
59
- {% endif %}
60
- <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Sign out</button></form>
2
+<nav class="bg-gray-900 border-b border-gray-700">
3
+ <div class="px-4 sm:px-6 lg:px-8">
4
+ <div class="flex h-14 items-center justify-between">
5
+ <div class="flex items-center gap-3">
6
+ <a href="{% url 'dashboard' %}" class="flex-shrink-0">
7
+ <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-7 w-auto">
8
+ </a>
9
+ </div>
10
+ <div class="flex items-center gap-3">
11
+ <!-- Theme toggle -->
12
+ <button x-data="{ dark: document.documentElement.classList.contains('dark') }"
13
+ @click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', dark ? 'dark' : 'light')"
14
+ class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800"
15
+ title="Toggle theme">
16
+ <svg x-show="dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
17
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
18
+ </svg>
19
+ <svg x-show="!dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none">
20
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
21
+ </svg>
22
+ </button>
23
+ <!-- User menu -->
24
+ <div class="relative" x-data="{ open: false }">
25
+ <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white">
26
+ {{ user.get_full_name|default:user.username }}
27
+ <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
28
+ </button>
29
+ <div x-show="open" @click.outside="open = false" x-transition
30
+ class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
31
+ <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form>
32
+ </div>
33
+ </div>
34
+ </div>
6135
</div>
6236
</div>
6337
</nav>
6438
6539
ADDED templates/includes/sidebar.html
6640
ADDED templates/organization/member_add.html
6741
ADDED templates/organization/member_confirm_remove.html
6842
ADDED templates/organization/member_list.html
6943
ADDED templates/organization/partials/member_table.html
7044
ADDED templates/organization/partials/team_member_table.html
7145
ADDED templates/organization/partials/team_table.html
7246
ADDED templates/organization/settings.html
7347
ADDED templates/organization/settings_form.html
7448
ADDED templates/organization/team_confirm_delete.html
7549
ADDED templates/organization/team_detail.html
7650
ADDED templates/organization/team_form.html
7751
ADDED templates/organization/team_list.html
7852
ADDED templates/organization/team_member_add.html
7953
ADDED templates/organization/team_member_confirm_remove.html
8054
ADDED templates/pages/page_confirm_delete.html
8155
ADDED templates/pages/page_detail.html
8256
ADDED templates/pages/page_form.html
8357
ADDED templates/pages/page_list.html
8458
ADDED templates/pages/partials/page_table.html
8559
ADDED templates/projects/partials/project_table.html
8660
ADDED templates/projects/partials/project_team_table.html
8761
ADDED templates/projects/project_confirm_delete.html
8862
ADDED templates/projects/project_detail.html
8963
ADDED templates/projects/project_form.html
9064
ADDED templates/projects/project_list.html
9165
ADDED templates/projects/project_team_add.html
9266
ADDED templates/projects/project_team_confirm_remove.html
9367
ADDED templates/projects/project_team_edit.html
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -1,63 +1,37 @@
1 {% load static %}
2 <nav class="bg-gray-900 border-b border-gray-700" x-data="{ mobileOpen: false }">
3 <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
4 <div class="flex h-16 items-center justify-between">
5 <div class="flex items-center">
6 <a href="{% url 'dashboard' %}" class="flex-shrink-0">
7 <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-8 w-auto">
8 </a>
9 <div class="hidden md:block ml-10">
10 <div class="flex items-baseline space-x-4">
11 <a href="{% url 'dashboard' %}"
12 class="{% if request.path == '/dashboard/' %}bg-brand text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium">
13 Dashboard
14 </a>
15 {% if perms.items.view_item %}
16 <a href="{% url 'items:list' %}"
17 class="{% if '/items/' in request.path %}bg-brand text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium">
18 Items
19 </a>
20 {% endif %}
21 {% if user.is_staff %}
22 <a href="{% url 'admin:index' %}"
23 class="text-gray-400 hover:bg-gray-800 hover:text-white rounded-md px-3 py-2 text-sm font-medium">
24 Admin
25 </a>
26 {% endif %}
27 </div>
28 </div>
29 </div>
30 <div class="hidden md:block">
31 <div class="ml-4 flex items-center md:ml-6" x-data="{ open: false }">
32 <div class="relative">
33 <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white">
34 {{ user.get_full_name|default:user.username }}
35 <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
36 </button>
37 <div x-show="open" @click.outside="open = false" x-transition
38 class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
39 <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form>
40 </div>
41 </div>
42 </div>
43 </div>
44 <div class="md:hidden">
45 <button @click="mobileOpen = !mobileOpen" class="text-gray-400 hover:text-white">
46 <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
47 <path x-show="!mobileOpen" stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
48 <path x-show="mobileOpen" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
49 </svg>
50 </button>
51 </div>
52 </div>
53 </div>
54 <div x-show="mobileOpen" class="md:hidden">
55 <div class="space-y-1 px-2 pb-3 pt-2">
56 <a href="{% url 'dashboard' %}" class="block rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Dashboard</a>
57 {% if perms.items.view_item %}
58 <a href="{% url 'items:list' %}" class="block rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Items</a>
59 {% endif %}
60 <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Sign out</button></form>
61 </div>
62 </div>
63 </nav>
64
65 DDED templates/includes/sidebar.html
66 DDED templates/organization/member_add.html
67 DDED templates/organization/member_confirm_remove.html
68 DDED templates/organization/member_list.html
69 DDED templates/organization/partials/member_table.html
70 DDED templates/organization/partials/team_member_table.html
71 DDED templates/organization/partials/team_table.html
72 DDED templates/organization/settings.html
73 DDED templates/organization/settings_form.html
74 DDED templates/organization/team_confirm_delete.html
75 DDED templates/organization/team_detail.html
76 DDED templates/organization/team_form.html
77 DDED templates/organization/team_list.html
78 DDED templates/organization/team_member_add.html
79 DDED templates/organization/team_member_confirm_remove.html
80 DDED templates/pages/page_confirm_delete.html
81 DDED templates/pages/page_detail.html
82 DDED templates/pages/page_form.html
83 DDED templates/pages/page_list.html
84 DDED templates/pages/partials/page_table.html
85 DDED templates/projects/partials/project_table.html
86 DDED templates/projects/partials/project_team_table.html
87 DDED templates/projects/project_confirm_delete.html
88 DDED templates/projects/project_detail.html
89 DDED templates/projects/project_form.html
90 DDED templates/projects/project_list.html
91 DDED templates/projects/project_team_add.html
92 DDED templates/projects/project_team_confirm_remove.html
93 DDED templates/projects/project_team_edit.html
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -1,63 +1,37 @@
1 {% load static %}
2 <nav class="bg-gray-900 border-b border-gray-700">
3 <div class="px-4 sm:px-6 lg:px-8">
4 <div class="flex h-14 items-center justify-between">
5 <div class="flex items-center gap-3">
6 <a href="{% url 'dashboard' %}" class="flex-shrink-0">
7 <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-7 w-auto">
8 </a>
9 </div>
10 <div class="flex items-center gap-3">
11 <!-- Theme toggle -->
12 <button x-data="{ dark: document.documentElement.classList.contains('dark') }"
13 @click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', dark ? 'dark' : 'light')"
14 class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800"
15 title="Toggle theme">
16 <svg x-show="dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
17 <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
18 </svg>
19 <svg x-show="!dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none">
20 <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
21 </svg>
22 </button>
23 <!-- User menu -->
24 <div class="relative" x-data="{ open: false }">
25 <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white">
26 {{ user.get_full_name|default:user.username }}
27 <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
28 </button>
29 <div x-show="open" @click.outside="open = false" x-transition
30 class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
31 <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form>
32 </div>
33 </div>
34 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35 </div>
36 </div>
37 </nav>
38
39 DDED templates/includes/sidebar.html
40 DDED templates/organization/member_add.html
41 DDED templates/organization/member_confirm_remove.html
42 DDED templates/organization/member_list.html
43 DDED templates/organization/partials/member_table.html
44 DDED templates/organization/partials/team_member_table.html
45 DDED templates/organization/partials/team_table.html
46 DDED templates/organization/settings.html
47 DDED templates/organization/settings_form.html
48 DDED templates/organization/team_confirm_delete.html
49 DDED templates/organization/team_detail.html
50 DDED templates/organization/team_form.html
51 DDED templates/organization/team_list.html
52 DDED templates/organization/team_member_add.html
53 DDED templates/organization/team_member_confirm_remove.html
54 DDED templates/pages/page_confirm_delete.html
55 DDED templates/pages/page_detail.html
56 DDED templates/pages/page_form.html
57 DDED templates/pages/page_list.html
58 DDED templates/pages/partials/page_table.html
59 DDED templates/projects/partials/project_table.html
60 DDED templates/projects/partials/project_team_table.html
61 DDED templates/projects/project_confirm_delete.html
62 DDED templates/projects/project_detail.html
63 DDED templates/projects/project_form.html
64 DDED templates/projects/project_list.html
65 DDED templates/projects/project_team_add.html
66 DDED templates/projects/project_team_confirm_remove.html
67 DDED templates/projects/project_team_edit.html
--- a/templates/includes/sidebar.html
+++ b/templates/includes/sidebar.html
@@ -0,0 +1,163 @@
1
+<aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }"
2
+ :class="collapsed ? 'w-14' : 'w-60'"
3
+ class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
4
+ <nav class="flex-1 px-2 py-4 space-y-1">
5
+
6
+ <!-- Collapse toggle -->
7
+ <button @click="collapsed = !collapsed; localStorage.setItem('sidebarCollapsed', collapsed)"
8
+ class="flex items-center justify-center w-full rounded-md px-2 py-2 text-gray-500 hover:text-white hover:bg-gray-800 mb-2"
9
+ :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
10
+ <svg x-show="!collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
11
+ <path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
12
+ </svg>
13
+ <svg x-show="collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none">
14
+ <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
15
+ </svg>
16
+ </button>
17
+
18
+ <!-- Dashboard -->
19
+ <a href="{% url 'dashboard' %}"
20
+ class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/dashboard/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
21
+ :title="collapsed ? 'Dashboard' : ''">
22
+ <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
23
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
24
+ </svg>
25
+ <span x-show="!collapsed" class="truncate">Dashboard</span>
26
+ </a>
27
+
28
+ <!-- Projects section -->
29
+ {% if perms.projects.view_project %}
30
+ <div>
31
+ <button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
32
+ class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/projects/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
33
+ :title="collapsed ? 'Projects' : ''">
34
+ <span class="flex items-center gap-2">
35
+ <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
36
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
37
+ </svg>
38
+ <span x-show="!collapsed" class="truncate">Projects</span>
39
+ </span>
40
+ <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
41
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
42
+ </svg>
43
+ </button>
44
+ <div x-show="projectsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-2">
45
+ {% for project in sidebar_projects %}
46
+ <div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }">
47
+ <button @click="open = !open"
48
+ class="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm {% if project.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}">
49
+ <span class="truncate">{{ project.name }}</span>
50
+ <svg class="h-3 w-3 flex-shrink-0 transition-transform" :class="open && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
51
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
52
+ </svg>
53
+ </button>
54
+ <div x-show="open" x-collapse class="ml-3 mt-0.5 space-y-0.5 border-l border-gray-700/50 pl-2">
55
+ <a href="{% url 'projects:detail' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
56
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg>
57
+ Overview
58
+ </a>
59
+ <a href="{% url 'fossil:code' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
60
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg>
61
+ Code
62
+ </a>
63
+ <a href="{% url 'fossil:timeline' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
64
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
65
+ Timeline
66
+ </a>
67
+ <a href="{% url 'fossil:tickets' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
68
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg>
69
+ Tickets
70
+ </a>
71
+ <a href="{% url 'fossil:wiki' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
72
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg>
73
+ Wiki
74
+ </a>
75
+ <a href="{% url 'fossil:forum' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
76
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg>
77
+ Forum
78
+ </a>
79
+ </div>
80
+ </div>
81
+ {% endfor %}
82
+ {% if perms.projects.add_project %}
83
+ <a href="{% url 'projects:create' %}"
84
+ class="block rounded-md px-2 py-1.5 text-sm text-gray-600 hover:text-brand-light">
85
+ + New
86
+ </a>
87
+ {% endif %}
88
+ </div>
89
+ </div>
90
+ {% endif %}
91
+
92
+ <!-- Teams -->
93
+ {% if perms.organization.view_team %}
94
+ <a href="{% url 'organization:team_list' %}"
95
+ class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/teams/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
96
+ :title="collapsed ? 'Teams' : ''">
97
+ <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
98
+ <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
99
+ </svg>
100
+ <span x-show="!collapsed" class="truncate">Teams</span>
101
+ </a>
102
+ {% endif %}
103
+
104
+ <!-- Docs section -->
105
+ {% if perms.pages.view_page %}
106
+ <div>
107
+ <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)"
108
+ class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/docs/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
109
+ :title="collapsed ? 'Docs' : ''">
110
+ <span class="flex items-center gap-2">
111
+ <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
112
+ <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" />
113
+ </svg>
114
+ <span x-show="!collapsed" class="truncate">Docs</span>
115
+ </span>
116
+ <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">
117
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
118
+ </svg>
119
+ </button>
120
+ <div x-show="docsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-3">
121
+ {% for p in sidebar_pages %}
122
+ <a href="{% url 'pages:detail' slug=p.slug %}"
123
+ class="block rounded-md px-3 py-1.5 text-sm {% if p.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %} truncate">
124
+ {{ p.name }}
125
+ </a>
126
+ {% endfor %}
127
+ {% if perms.pages.add_page %}
128
+ <a href="{% url 'pages:create' %}"
129
+ class="block rounded-md px-3 py-1.5 text-sm text-gray-600 hover:text-brand-light">
130
+ + New
131
+ </a>
132
+ {% endif %}
133
+ </div>
134
+ </div>
135
+ {% endif %}
136
+
137
+ <!-- Settings -->
138
+ {% if perms.organization.view_organization %}
139
+ <a href="{% url 'organization:settings' %}"
140
+ class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/' in request.path and '/settings/teams/' not in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
141
+ :title="collapsed ? 'Settings' : ''">
142
+ <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
143
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
144
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
145
+ </svg>
146
+ <span x-show="!collapsed" class="truncate">Settings</span>
147
+ </a>
148
+ {% endif %}
149
+
150
+ <!-- Admin -->
151
+ {% if user.is_staff %}
152
+ <a href="{% url 'admin:index' %}"
153
+ class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white"
154
+ :title="collapsed ? 'Admin' : ''">
155
+ <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
156
+ <path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.385 3.171a.75.75 0 01-1.089-.791l1.414-5.816-4.534-3.89a.75.75 0 01.422-1.317l5.953-.493 2.354-5.476a.75.75 0 011.39 0l2.354 5.476 5.953.493a.75.75 0 01.422 1.317l-4.534 3.89 1.414 5.816a.75.75 0 01-1.089.791L12 15.17l-.58.341z" />
157
+ </svg>
158
+ <span x-show="!collapsed" class="truncate">Admin</span>
159
+ </a>
160
+ {% endif %}
161
+
162
+ </nav>
163
+</aside>
--- a/templates/includes/sidebar.html
+++ b/templates/includes/sidebar.html
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/includes/sidebar.html
+++ b/templates/includes/sidebar.html
@@ -0,0 +1,163 @@
1 <aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }"
2 :class="collapsed ? 'w-14' : 'w-60'"
3 class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
4 <nav class="flex-1 px-2 py-4 space-y-1">
5
6 <!-- Collapse toggle -->
7 <button @click="collapsed = !collapsed; localStorage.setItem('sidebarCollapsed', collapsed)"
8 class="flex items-center justify-center w-full rounded-md px-2 py-2 text-gray-500 hover:text-white hover:bg-gray-800 mb-2"
9 :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
10 <svg x-show="!collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
11 <path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
12 </svg>
13 <svg x-show="collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none">
14 <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
15 </svg>
16 </button>
17
18 <!-- Dashboard -->
19 <a href="{% url 'dashboard' %}"
20 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/dashboard/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
21 :title="collapsed ? 'Dashboard' : ''">
22 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
23 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
24 </svg>
25 <span x-show="!collapsed" class="truncate">Dashboard</span>
26 </a>
27
28 <!-- Projects section -->
29 {% if perms.projects.view_project %}
30 <div>
31 <button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
32 class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/projects/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
33 :title="collapsed ? 'Projects' : ''">
34 <span class="flex items-center gap-2">
35 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
36 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
37 </svg>
38 <span x-show="!collapsed" class="truncate">Projects</span>
39 </span>
40 <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
41 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
42 </svg>
43 </button>
44 <div x-show="projectsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-2">
45 {% for project in sidebar_projects %}
46 <div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }">
47 <button @click="open = !open"
48 class="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm {% if project.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}">
49 <span class="truncate">{{ project.name }}</span>
50 <svg class="h-3 w-3 flex-shrink-0 transition-transform" :class="open && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
51 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
52 </svg>
53 </button>
54 <div x-show="open" x-collapse class="ml-3 mt-0.5 space-y-0.5 border-l border-gray-700/50 pl-2">
55 <a href="{% url 'projects:detail' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
56 <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg>
57 Overview
58 </a>
59 <a href="{% url 'fossil:code' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
60 <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg>
61 Code
62 </a>
63 <a href="{% url 'fossil:timeline' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
64 <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
65 Timeline
66 </a>
67 <a href="{% url 'fossil:tickets' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
68 <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg>
69 Tickets
70 </a>
71 <a href="{% url 'fossil:wiki' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
72 <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg>
73 Wiki
74 </a>
75 <a href="{% url 'fossil:forum' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300">
76 <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg>
77 Forum
78 </a>
79 </div>
80 </div>
81 {% endfor %}
82 {% if perms.projects.add_project %}
83 <a href="{% url 'projects:create' %}"
84 class="block rounded-md px-2 py-1.5 text-sm text-gray-600 hover:text-brand-light">
85 + New
86 </a>
87 {% endif %}
88 </div>
89 </div>
90 {% endif %}
91
92 <!-- Teams -->
93 {% if perms.organization.view_team %}
94 <a href="{% url 'organization:team_list' %}"
95 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/teams/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
96 :title="collapsed ? 'Teams' : ''">
97 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
98 <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
99 </svg>
100 <span x-show="!collapsed" class="truncate">Teams</span>
101 </a>
102 {% endif %}
103
104 <!-- Docs section -->
105 {% if perms.pages.view_page %}
106 <div>
107 <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)"
108 class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/docs/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
109 :title="collapsed ? 'Docs' : ''">
110 <span class="flex items-center gap-2">
111 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
112 <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" />
113 </svg>
114 <span x-show="!collapsed" class="truncate">Docs</span>
115 </span>
116 <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">
117 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
118 </svg>
119 </button>
120 <div x-show="docsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-3">
121 {% for p in sidebar_pages %}
122 <a href="{% url 'pages:detail' slug=p.slug %}"
123 class="block rounded-md px-3 py-1.5 text-sm {% if p.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %} truncate">
124 {{ p.name }}
125 </a>
126 {% endfor %}
127 {% if perms.pages.add_page %}
128 <a href="{% url 'pages:create' %}"
129 class="block rounded-md px-3 py-1.5 text-sm text-gray-600 hover:text-brand-light">
130 + New
131 </a>
132 {% endif %}
133 </div>
134 </div>
135 {% endif %}
136
137 <!-- Settings -->
138 {% if perms.organization.view_organization %}
139 <a href="{% url 'organization:settings' %}"
140 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/' in request.path and '/settings/teams/' not in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
141 :title="collapsed ? 'Settings' : ''">
142 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
143 <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
144 <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
145 </svg>
146 <span x-show="!collapsed" class="truncate">Settings</span>
147 </a>
148 {% endif %}
149
150 <!-- Admin -->
151 {% if user.is_staff %}
152 <a href="{% url 'admin:index' %}"
153 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white"
154 :title="collapsed ? 'Admin' : ''">
155 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
156 <path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.385 3.171a.75.75 0 01-1.089-.791l1.414-5.816-4.534-3.89a.75.75 0 01.422-1.317l5.953-.493 2.354-5.476a.75.75 0 011.39 0l2.354 5.476 5.953.493a.75.75 0 01.422 1.317l-4.534 3.89 1.414 5.816a.75.75 0 01-1.089.791L12 15.17l-.58.341z" />
157 </svg>
158 <span x-show="!collapsed" class="truncate">Admin</span>
159 </a>
160 {% endif %}
161
162 </nav>
163 </aside>
--- a/templates/organization/member_add.html
+++ b/templates/organization/member_add.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Add Member — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'organization:members' %}"
29
+ 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">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Add Member
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/organization/member_add.html
+++ b/templates/organization/member_add.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/member_add.html
+++ b/templates/organization/member_add.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Add Member — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'organization:members' %}"
29 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">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Add Member
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/organization/member_confirm_remove.html
+++ b/templates/organization/member_confirm_remove.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Remove Member — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</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">Remove Member</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to remove <strong class="text-gray-100">{{ membership.member.username }}</strong> from the organization? This action uses soft delete — the membership can be recovered.
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'organization:members' %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Remove
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/organization/member_confirm_remove.html
+++ b/templates/organization/member_confirm_remove.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/member_confirm_remove.html
+++ b/templates/organization/member_confirm_remove.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Remove Member — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</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">Remove Member</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to remove <strong class="text-gray-100">{{ membership.member.username }}</strong> from the organization? This action uses soft delete — the membership can be recovered.
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'organization:members' %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Remove
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/organization/member_list.html
+++ b/templates/organization/member_list.html
@@ -0,0 +1,30 @@
1
+{% extends "base.html" %}
2
+{% block title %}Members — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7
+</div>
8
+
9
+<div class="md:flex md:items-center md:justify-between mb-6">
10
+ <h1 class="text-2xl font-bold tex{% if perms.organization.ar user.is_superuser %}
11
+ <a member_add' %}"
12
+ ay-100">Members</h1>
13
+_create' %}"
14
+ class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semAdd Member
15
+ </a>
16
+ {% endif %}
17
+ <input type="search"
18
+ name="search"
19
+ value="{{ search }}"
20
+ placeholder="Search members..."
21
+ class="w-full max-w-mders"
22
+ class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
23
+ hx-get="{% url 'organization:members' %}"
24
+ hx-trigger="input changed delay:300ms, search"
25
+ hx-target="#member-table"
26
+ />
27
+</div>
28
+
29
+{% include "organization/partials/member_table.html" %}
30
+{% include "includes/_paginatiendblock %}
--- a/templates/organization/member_list.html
+++ b/templates/organization/member_list.html
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/member_list.html
+++ b/templates/organization/member_list.html
@@ -0,0 +1,30 @@
1 {% extends "base.html" %}
2 {% block title %}Members — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7 </div>
8
9 <div class="md:flex md:items-center md:justify-between mb-6">
10 <h1 class="text-2xl font-bold tex{% if perms.organization.ar user.is_superuser %}
11 <a member_add' %}"
12 ay-100">Members</h1>
13 _create' %}"
14 class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semAdd Member
15 </a>
16 {% endif %}
17 <input type="search"
18 name="search"
19 value="{{ search }}"
20 placeholder="Search members..."
21 class="w-full max-w-mders"
22 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
23 hx-get="{% url 'organization:members' %}"
24 hx-trigger="input changed delay:300ms, search"
25 hx-target="#member-table"
26 />
27 </div>
28
29 {% include "organization/partials/member_table.html" %}
30 {% include "includes/_paginatiendblock %}
--- a/templates/organization/partials/member_table.html
+++ b/templates/organization/partials/member_table.html
@@ -0,0 +1,18 @@
1
+<div id="member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">table">
4
+ <div class="over<<div id="member-table">
5
+ <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
6
+ <table class="min-w-full divide-y divide-gray-700">
7
+ <thead class="bg-gray-900/80">
8
+ <tr>
9
+ <th class="px-6Status>
10
+ <th0">Username</th>
11
+ il</th>
12
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Role</th>
13
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Status</th>
14
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Joined</th>
15
+ text-gray-100">
16
+ 5"s="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-400">Actions</th>
17
+ </tr>
18
+
--- a/templates/organization/partials/member_table.html
+++ b/templates/organization/partials/member_table.html
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/partials/member_table.html
+++ b/templates/organization/partials/member_table.html
@@ -0,0 +1,18 @@
1 <div id="member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">table">
4 <div class="over<<div id="member-table">
5 <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
6 <table class="min-w-full divide-y divide-gray-700">
7 <thead class="bg-gray-900/80">
8 <tr>
9 <th class="px-6Status>
10 <th0">Username</th>
11 il</th>
12 <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Role</th>
13 <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Status</th>
14 <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Joined</th>
15 text-gray-100">
16 5"s="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-400">Actions</th>
17 </tr>
18
--- a/templates/organization/partials/team_member_table.html
+++ b/templates/organization/partials/team_member_table.html
@@ -0,0 +1,21 @@
1
+<div id="team-member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <tr>
5
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Email</th>
6
+ <th class="px-6 py-3 text-right text bg-gray-800">
7
+ {% for member in team_members %}
8
+ <tr class="hover:bg-gray-700/50">
9
+ <td class="px-6 psm font-medium nowrap text-sm font-medium text-gray-100">{{ member.username }}</td>
10
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ member.email|default:"—" }}</td>
11
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
12
+ {% if perms.organization.change_team %}
13
+ <a href="{% url 'organization:team_member_remove' slug=team.slug username=member.username %}" class="text-red-400 hover:text-red-300">Remove</a>
14
+ {% endif %}
15
+ </td>
16
+ </tr>
17
+ {% empty %}
18
+ <tr>
19
+ <td colspan="3" class="px-6 py-8 text-center text-sm text-gray-400">No members yet.</td>
20
+ </tr>
21
+ {%
--- a/templates/organization/partials/team_member_table.html
+++ b/templates/organization/partials/team_member_table.html
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/partials/team_member_table.html
+++ b/templates/organization/partials/team_member_table.html
@@ -0,0 +1,21 @@
1 <div id="team-member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <tr>
5 <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Email</th>
6 <th class="px-6 py-3 text-right text bg-gray-800">
7 {% for member in team_members %}
8 <tr class="hover:bg-gray-700/50">
9 <td class="px-6 psm font-medium nowrap text-sm font-medium text-gray-100">{{ member.username }}</td>
10 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ member.email|default:"—" }}</td>
11 <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
12 {% if perms.organization.change_team %}
13 <a href="{% url 'organization:team_member_remove' slug=team.slug username=member.username %}" class="text-red-400 hover:text-red-300">Remove</a>
14 {% endif %}
15 </td>
16 </tr>
17 {% empty %}
18 <tr>
19 <td colspan="3" class="px-6 py-8 text-center text-sm text-gray-400">No members yet.</td>
20 </tr>
21 {%
--- a/templates/organization/partials/team_table.html
+++ b/templates/organization/partials/team_table.html
@@ -0,0 +1,26 @@
1
+<div id="team-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <tr>
5
+ <th class="px-6 py-3 text-left text uppercase tracking-wider text-gray-400">Name</th>
6
+ <th class="px-6 py-3 text-left textext-gray-400">Members</th>
7
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800">
8
+ {% for team in teams %}
9
+ <t50">
10
+ <td class=""">Action <a href=detail' slug=team.slug %}" class="text-brand-light hover:text-brand font-medium">
11
+ {{ team.name }}
12
+ </a>
13
+ </td>
14
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.members.count }}</td>
15
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.created_at|date:"N j, Y" }}</td>
16
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
17
+ {% if perms.organization.change_team %}
18
+ <a href="{% url 'organization:team_update' slug=team.slug %}" class="text-brand-light hover:text-brand">Edit</a>
19
+ {% endif %}
20
+ </td>
21
+ </tr>
22
+ {% empty %}
23
+ <tr>
24
+ <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No teams found.</td>
25
+ </tr>
26
+ {%
--- a/templates/organization/partials/team_table.html
+++ b/templates/organization/partials/team_table.html
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/partials/team_table.html
+++ b/templates/organization/partials/team_table.html
@@ -0,0 +1,26 @@
1 <div id="team-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <tr>
5 <th class="px-6 py-3 text-left text uppercase tracking-wider text-gray-400">Name</th>
6 <th class="px-6 py-3 text-left textext-gray-400">Members</th>
7 <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800">
8 {% for team in teams %}
9 <t50">
10 <td class=""">Action <a href=detail' slug=team.slug %}" class="text-brand-light hover:text-brand font-medium">
11 {{ team.name }}
12 </a>
13 </td>
14 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.members.count }}</td>
15 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.created_at|date:"N j, Y" }}</td>
16 <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
17 {% if perms.organization.change_team %}
18 <a href="{% url 'organization:team_update' slug=team.slug %}" class="text-brand-light hover:text-brand">Edit</a>
19 {% endif %}
20 </td>
21 </tr>
22 {% empty %}
23 <tr>
24 <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No teams found.</td>
25 </tr>
26 {%
--- a/templates/organization/settings.html
+++ b/templates/organization/settings.html
@@ -0,0 +1,32 @@
1
+{% extends "base.html" %}
2
+{% block title %}Settings — 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">Organization Settings</h1>
7
+ {% if perms.organization.change_organization %}
8
+ <a href="{% url 'organization:settings_edit' %}"
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
+ Edit Settings
11
+ </a>
12
+ {% endif %}
13
+</div>
14
+
15
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
16
+ <div class="px-6 py-5">
17
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
18
+ <div>
19
+ <dt class="text-sm font-medium text-gray-400">Name</dt>
20
+ <dd class="mt-1 text-sm text-gray-100">{{ org.name }}</dd>
21
+ </div>
22
+ <div>
23
+ <dt class="text-sm font-medium text-gray-400">Slug</dt>
24
+ <dd class="mt-1 text-sm text-gray-400 font-mono">{{ org.slug }}</dd>
25
+ </div>
26
+ <div>
27
+ <dt class="text-sm font-medium text-gray-400">Website</dt>
28
+ <dd class="mt-1 text-sm text-gray-100">{{ org.website|default:"—" }}</dd>
29
+ </div>
30
+ <div>
31
+ <dt class="text-sm font-medium text-gray-400">GUID</dt>
32
+ <dd class="mt-1 text-sm{% endblock %}
--- a/templates/organization/settings.html
+++ b/templates/organization/settings.html
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/settings.html
+++ b/templates/organization/settings.html
@@ -0,0 +1,32 @@
1 {% extends "base.html" %}
2 {% block title %}Settings — 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">Organization Settings</h1>
7 {% if perms.organization.change_organization %}
8 <a href="{% url 'organization:settings_edit' %}"
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 Edit Settings
11 </a>
12 {% endif %}
13 </div>
14
15 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
16 <div class="px-6 py-5">
17 <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
18 <div>
19 <dt class="text-sm font-medium text-gray-400">Name</dt>
20 <dd class="mt-1 text-sm text-gray-100">{{ org.name }}</dd>
21 </div>
22 <div>
23 <dt class="text-sm font-medium text-gray-400">Slug</dt>
24 <dd class="mt-1 text-sm text-gray-400 font-mono">{{ org.slug }}</dd>
25 </div>
26 <div>
27 <dt class="text-sm font-medium text-gray-400">Website</dt>
28 <dd class="mt-1 text-sm text-gray-100">{{ org.website|default:"—" }}</dd>
29 </div>
30 <div>
31 <dt class="text-sm font-medium text-gray-400">GUID</dt>
32 <dd class="mt-1 text-sm{% endblock %}
--- a/templates/organization/settings_form.html
+++ b/templates/organization/settings_form.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Edit Settings — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit Organization Settings</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'organization:settings' %}"
29
+ 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">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Save
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/organization/settings_form.html
+++ b/templates/organization/settings_form.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/settings_form.html
+++ b/templates/organization/settings_form.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Edit Settings — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit Organization Settings</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'organization:settings' %}"
29 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">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Save
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/organization/team_confirm_delete.html
+++ b/templates/organization/team_confirm_delete.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Delete {{ team.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.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 Team</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to delete <strong class="text-gray-100">{{ team.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'organization:team_detail' slug=team.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Delete
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/organization/team_confirm_delete.html
+++ b/templates/organization/team_confirm_delete.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_confirm_delete.html
+++ b/templates/organization/team_confirm_delete.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Delete {{ team.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.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 Team</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to delete <strong class="text-gray-100">{{ team.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'organization:team_detail' slug=team.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Delete
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/organization/team_detail.html
+++ b/templates/organization/team_detail.html
@@ -0,0 +1,62 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ team.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Teams</a>
7
+</div>
8
+
9
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10
+ <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between">
11
+ <div>
12
+ <h1 class="text-2xl font-bold text-gray-100">{{ team.name }}</h1>
13
+ <p class="mt-1 text-sm text-gray-400">{{ team.slug }}</p>
14
+ </div>
15
+ <div class="mt-4 flex gap-3 sm:mt-0">
16
+ {% if perms.organization.change_team %}
17
+ <a href="{% url 'organization:team_update' slug=team.slug %}"
18
+ 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">
19
+ Edit
20
+ </a>
21
+ {% endif %}
22
+ {% if perms.organization.delete_team %}
23
+ <a href="{% url 'organization:team_delete' slug=team.slug %}"
24
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
25
+ Delete
26
+ </a>
27
+ {% endif %}
28
+ </div>
29
+ </div>
30
+
31
+ <div class="border-t border-gray-700 px-6 py-5">
32
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
33
+ <div class="sm:col-span-2">
34
+ <dt class="text-sm font-medium text-gray-400">Description</dt>
35
+ <dd class="mt-1 text-sm text-gray-100">{{ team.description|default:"No description." }}</dd>
36
+ </div>
37
+ <div>
38
+ <dt class="text-sm font-medium text-gray-400">GUID</dt>
39
+ <dd class="mt-1 text-sm text-gray-400 font-mono">{{ team.guid }}</dd>
40
+ </div>
41
+ <div>
42
+ <dt class="text-sm font-medium text-gray-400">Created</dt>
43
+ <dd class="mt-1 text-sm text-gray-400">{{ team.created_at|date:"N j, Y g:i a" }} by {{ team.created_by|default:"system" }}</dd>
44
+ </div>
45
+ </dl>
46
+ </div>
47
+</div>
48
+
49
+<div class="mt-8">
50
+ <div class="md:flex md:items-center md:justify-between mb-4">
51
+ <h2 class="text-lg font-semibold text-gray-100">Team Members</h2>
52
+ {% if perms.organization.change_team %}
53
+ <a href="{% url 'organization:team_member_add' slug=team.slug %}"
54
+ 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">
55
+ Add Member
56
+ </a>
57
+ {% endif %}
58
+ </div>
59
+
60
+ {% include "organization/partials/team_member_table.html" %}
61
+</div>
62
+{% endblock %}
--- a/templates/organization/team_detail.html
+++ b/templates/organization/team_detail.html
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_detail.html
+++ b/templates/organization/team_detail.html
@@ -0,0 +1,62 @@
1 {% extends "base.html" %}
2 {% block title %}{{ team.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Teams</a>
7 </div>
8
9 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10 <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between">
11 <div>
12 <h1 class="text-2xl font-bold text-gray-100">{{ team.name }}</h1>
13 <p class="mt-1 text-sm text-gray-400">{{ team.slug }}</p>
14 </div>
15 <div class="mt-4 flex gap-3 sm:mt-0">
16 {% if perms.organization.change_team %}
17 <a href="{% url 'organization:team_update' slug=team.slug %}"
18 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">
19 Edit
20 </a>
21 {% endif %}
22 {% if perms.organization.delete_team %}
23 <a href="{% url 'organization:team_delete' slug=team.slug %}"
24 class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
25 Delete
26 </a>
27 {% endif %}
28 </div>
29 </div>
30
31 <div class="border-t border-gray-700 px-6 py-5">
32 <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
33 <div class="sm:col-span-2">
34 <dt class="text-sm font-medium text-gray-400">Description</dt>
35 <dd class="mt-1 text-sm text-gray-100">{{ team.description|default:"No description." }}</dd>
36 </div>
37 <div>
38 <dt class="text-sm font-medium text-gray-400">GUID</dt>
39 <dd class="mt-1 text-sm text-gray-400 font-mono">{{ team.guid }}</dd>
40 </div>
41 <div>
42 <dt class="text-sm font-medium text-gray-400">Created</dt>
43 <dd class="mt-1 text-sm text-gray-400">{{ team.created_at|date:"N j, Y g:i a" }} by {{ team.created_by|default:"system" }}</dd>
44 </div>
45 </dl>
46 </div>
47 </div>
48
49 <div class="mt-8">
50 <div class="md:flex md:items-center md:justify-between mb-4">
51 <h2 class="text-lg font-semibold text-gray-100">Team Members</h2>
52 {% if perms.organization.change_team %}
53 <a href="{% url 'organization:team_member_add' slug=team.slug %}"
54 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">
55 Add Member
56 </a>
57 {% endif %}
58 </div>
59
60 {% include "organization/partials/team_member_table.html" %}
61 </div>
62 {% endblock %}
--- a/templates/organization/team_form.html
+++ b/templates/organization/team_form.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Teams</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'organization:team_list' %}"
29
+ 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">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ {% if team %}Update{% else %}Create{% endif %}
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/organization/team_form.html
+++ b/templates/organization/team_form.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_form.html
+++ b/templates/organization/team_form.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Teams</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'organization:team_list' %}"
29 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">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 {% if team %}Update{% else %}Create{% endif %}
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/organization/team_list.html
+++ b/templates/organization/team_list.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Teams — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7
+</div>
8
+
9
+<div class="md:flex md:items-center md:justify-between mb-6">
10
+ <h1 class="text-2xl font-bold text-gray-100">Teams</h1>
11
+ {% if perms.organization.add_team %}
12
+ <a href="{% url 'organization:team_create' %}"
13
+ 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">
14
+ New Team
15
+ </a>
16
+ {% endif %}
17
+</div>
18
+
19
+<div class="mb-4class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
20
+ hx-get="{% url 'organization:team_list' %}"
21
+ hx-trigger="input changed delay:300ms, search"
22
+ hx-target="#team-table"
23
+ hx-swap="outerHTML"
24
+ />
25
+</div>
26
+
27
+{% include "organization/partials/team_table.html" %}
28
+{% include "includes/_paginatiendblock %}
--- a/templates/organization/team_list.html
+++ b/templates/organization/team_list.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_list.html
+++ b/templates/organization/team_list.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Teams — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7 </div>
8
9 <div class="md:flex md:items-center md:justify-between mb-6">
10 <h1 class="text-2xl font-bold text-gray-100">Teams</h1>
11 {% if perms.organization.add_team %}
12 <a href="{% url 'organization:team_create' %}"
13 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">
14 New Team
15 </a>
16 {% endif %}
17 </div>
18
19 <div class="mb-4class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
20 hx-get="{% url 'organization:team_list' %}"
21 hx-trigger="input changed delay:300ms, search"
22 hx-target="#team-table"
23 hx-swap="outerHTML"
24 />
25 </div>
26
27 {% include "organization/partials/team_table.html" %}
28 {% include "includes/_paginatiendblock %}
--- a/templates/organization/team_member_add.html
+++ b/templates/organization/team_member_add.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Add Member to {{ team.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member to {{ team.name }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'organization:team_detail' slug=team.slug %}"
29
+ 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">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Add Member
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/organization/team_member_add.html
+++ b/templates/organization/team_member_add.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_member_add.html
+++ b/templates/organization/team_member_add.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Add Member to {{ team.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member to {{ team.name }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'organization:team_detail' slug=team.slug %}"
29 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">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Add Member
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/organization/team_member_confirm_remove.html
+++ b/templates/organization/team_member_confirm_remove.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Remove Member — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.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">Remove Member</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to remove <strong class="text-gray-100">{{ member_user.username }}</strong> from <strong class="text-gray-100">{{ team.name }}</strong>?
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'organization:team_detail' slug=team.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Remove
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/organization/team_member_confirm_remove.html
+++ b/templates/organization/team_member_confirm_remove.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_member_confirm_remove.html
+++ b/templates/organization/team_member_confirm_remove.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Remove Member — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.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">Remove Member</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to remove <strong class="text-gray-100">{{ member_user.username }}</strong> from <strong class="text-gray-100">{{ team.name }}</strong>?
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'organization:team_detail' slug=team.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Remove
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/pages/page_confirm_delete.html
+++ b/templates/pages/page_confirm_delete.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Delete {{ page.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'pages:detail' slug=page.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ page.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 Page</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to delete <strong class="text-gray-100">{{ page.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'pages:detail' slug=page.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Delete
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/pages/page_confirm_delete.html
+++ b/templates/pages/page_confirm_delete.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/page_confirm_delete.html
+++ b/templates/pages/page_confirm_delete.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Delete {{ page.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'pages:detail' slug=page.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ page.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 Page</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to delete <strong class="text-gray-100">{{ page.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'pages:detail' slug=page.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Delete
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/pages/page_detail.html
+++ b/templates/pages/page_detail.html
@@ -0,0 +1,38 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ page.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
6
+ <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between border-b border-gray-700">
7
+ <div>
8
+ <h1 class="text-2xl font-bold text-gray-100">{{ page.name }}</h1>
9
+ <p class="mt-1 text-sm text-gray-400">
10
+ {% if not page.is_published %}
11
+ <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300 mr-2">Draft</span>
12
+ {% endif %}
13
+ Updated {{ page.updated_at|date:"N j, Y g:i a" }}{% if page.updated_by %} by {{ page.updated_by }}{% endif %}
14
+ </p>
15
+ </div>
16
+ <div class="mt-4 flex gap-3 sm:mt-0">
17
+ {% if perms.pages.change_page %}
18
+ <a href="{% url 'pages:update' slug=page.slug %}"
19
+ 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">
20
+ Edit
21
+ </a>
22
+ {% endif %}
23
+ {% if perms.pages.delete_page %}
24
+ <a href="{% url 'pages:delete' slug=page.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="px-6 py-6">
33
+ <div class="prose prose-invert prose-gray max-w-none">
34
+ {{ content_html }}
35
+ </div>
36
+ </div>
37
+</div>
38
+{% endblock %}
--- a/templates/pages/page_detail.html
+++ b/templates/pages/page_detail.html
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/page_detail.html
+++ b/templates/pages/page_detail.html
@@ -0,0 +1,38 @@
1 {% extends "base.html" %}
2 {% block title %}{{ page.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
6 <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between border-b border-gray-700">
7 <div>
8 <h1 class="text-2xl font-bold text-gray-100">{{ page.name }}</h1>
9 <p class="mt-1 text-sm text-gray-400">
10 {% if not page.is_published %}
11 <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300 mr-2">Draft</span>
12 {% endif %}
13 Updated {{ page.updated_at|date:"N j, Y g:i a" }}{% if page.updated_by %} by {{ page.updated_by }}{% endif %}
14 </p>
15 </div>
16 <div class="mt-4 flex gap-3 sm:mt-0">
17 {% if perms.pages.change_page %}
18 <a href="{% url 'pages:update' slug=page.slug %}"
19 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">
20 Edit
21 </a>
22 {% endif %}
23 {% if perms.pages.delete_page %}
24 <a href="{% url 'pages:delete' slug=page.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="px-6 py-6">
33 <div class="prose prose-invert prose-gray max-w-none">
34 {{ content_html }}
35 </div>
36 </div>
37 </div>
38 {% endblock %}
--- a/templates/pages/page_form.html
+++ b/templates/pages/page_form.html
@@ -0,0 +1,37 @@
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"KBrr; Back to FoDocsr; Back to FoKnowledge Baserr; Back to FossilRepo Docs</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
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ {% if field.help_text %}
25
+ <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p>
26
+ {% endif %}
27
+ </div>
28
+ {% endfor %}
29
+
30
+ <div class="flex justify-end gap-3 pt-4">
31
+ <a href="{% url 'pages:list' %}"
32
+ 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">
33
+ Cancel
34
+ </a>
35
+ <button type="submit"
36
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
37
+ {% if page %}Update{% els
--- a/templates/pages/page_form.html
+++ b/templates/pages/page_form.html
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/page_form.html
+++ b/templates/pages/page_form.html
@@ -0,0 +1,37 @@
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"KBrr; Back to FoDocsr; Back to FoKnowledge Baserr; Back to FossilRepo Docs</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 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 {% if field.help_text %}
25 <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p>
26 {% endif %}
27 </div>
28 {% endfor %}
29
30 <div class="flex justify-end gap-3 pt-4">
31 <a href="{% url 'pages:list' %}"
32 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">
33 Cancel
34 </a>
35 <button type="submit"
36 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
37 {% if page %}Update{% els
--- a/templates/pages/page_list.html
+++ b/templates/pages/page_list.html
@@ -0,0 +1,28 @@
1
+{% extends "baseDocsock title %}FossilRepo Docs — Fossilrepo{% endblock %}
2
+
3
+{% block content %}
4
+<div class="md:flex md:items-center md:justify-between mb-6">
5
+ <hKBt-gray-100">FoDocs</h1>
6
+ {% if perms.pages.add_page %}
7
+ <a href="{% url 'pages:create' %}"
8
+ 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">
9
+ New Page
10
+ </a>
11
+ {% endif %}
12
+</div>
13
+
14
+<div class="mb-4">
15
+ <input type="search"
16
+ name="search"
17
+ value="{{ search }}"
18
+ placeholder="Search docs..."
19
+ class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
20
+ hx-get="{% url 'pages:list' %}"
21
+ hx-trigger="input changed delay:300ms, search"
22
+ hx-target="#page-table"
23
+ hx-swap="outerHTML"
24
+ />
25
+</div>
26
+
27
+{% include "pages/partials/page_table.html" %}
28
+{% include "includes/_paginatiendblock %}
--- a/templates/pages/page_list.html
+++ b/templates/pages/page_list.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/page_list.html
+++ b/templates/pages/page_list.html
@@ -0,0 +1,28 @@
1 {% extends "baseDocsock title %}FossilRepo Docs — Fossilrepo{% endblock %}
2
3 {% block content %}
4 <div class="md:flex md:items-center md:justify-between mb-6">
5 <hKBt-gray-100">FoDocs</h1>
6 {% if perms.pages.add_page %}
7 <a href="{% url 'pages:create' %}"
8 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">
9 New Page
10 </a>
11 {% endif %}
12 </div>
13
14 <div class="mb-4">
15 <input type="search"
16 name="search"
17 value="{{ search }}"
18 placeholder="Search docs..."
19 class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
20 hx-get="{% url 'pages:list' %}"
21 hx-trigger="input changed delay:300ms, search"
22 hx-target="#page-table"
23 hx-swap="outerHTML"
24 />
25 </div>
26
27 {% include "pages/partials/page_table.html" %}
28 {% include "includes/_paginatiendblock %}
--- a/templates/pages/partials/page_table.html
+++ b/templates/pages/partials/page_table.html
@@ -0,0 +1,37 @@
1
+<div id="page-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <tr>
5
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Title</th>
6
+ <th class="px-6 py-3 text-left textext-gray-400">Status</th>
7
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800">
8
+ {% for page in pages %}
9
+ <t50">
10
+ 6 py-4 wh<td class="px-6 py-4 whitespace-nowrap">
11
+ <a href="{% url 'pages:detail' slug=page.slug %}" class="text-brand-light hover:text-brand font-medium">
12
+ {{ page.name }}
13
+ </a>
14
+ </td>
15
+ <td class="px-6 py-4 whitespace-nowrap">
16
+ {% if page.is_published %}
17
+ <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Published</span>
18
+ {% else %}
19
+ <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300">Draft</span>
20
+ {% endif %}
21
+ </td>
22
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ page.updated_at|date:"N j, Y" }}</td>
23
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
24
+ {% if perms.pages.change_page %}
25
+ <a href="{% url 'pages:update' slug=page.slug %}" class="text-brand-light hover:text-brand">Edit</a>
26
+ {% endif %}
27
+ </td>
28
+ </tr>
29
+ {% empty %}
30
+ <tr>
31
+ <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No pages found.</td>
32
+ </tr>
33
+ {% endfor %}
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+</div>
--- a/templates/pages/partials/page_table.html
+++ b/templates/pages/partials/page_table.html
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/partials/page_table.html
+++ b/templates/pages/partials/page_table.html
@@ -0,0 +1,37 @@
1 <div id="page-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <tr>
5 <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Title</th>
6 <th class="px-6 py-3 text-left textext-gray-400">Status</th>
7 <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800">
8 {% for page in pages %}
9 <t50">
10 6 py-4 wh<td class="px-6 py-4 whitespace-nowrap">
11 <a href="{% url 'pages:detail' slug=page.slug %}" class="text-brand-light hover:text-brand font-medium">
12 {{ page.name }}
13 </a>
14 </td>
15 <td class="px-6 py-4 whitespace-nowrap">
16 {% if page.is_published %}
17 <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Published</span>
18 {% else %}
19 <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300">Draft</span>
20 {% endif %}
21 </td>
22 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ page.updated_at|date:"N j, Y" }}</td>
23 <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
24 {% if perms.pages.change_page %}
25 <a href="{% url 'pages:update' slug=page.slug %}" class="text-brand-light hover:text-brand">Edit</a>
26 {% endif %}
27 </td>
28 </tr>
29 {% empty %}
30 <tr>
31 <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No pages found.</td>
32 </tr>
33 {% endfor %}
34 </tbody>
35 </table>
36 </div>
37 </div>
--- a/templates/projects/partials/project_table.html
+++ b/templates/projects/partials/project_table.html
@@ -0,0 +1,10 @@
1
+<div id="project-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <tr>
5
+ <div id="project-table">
6
+ <div class="overflow-x-auto rounded-lg border border-gray-700 bg-grayext-gray-400">Visibility</th>
7
+ <th class="px-6 py-3 text-left textext-gray-400">Teams</th>
8
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase t bg-gray-800">
9
+ {% for project in projects %}
10
+ <tr class="hover:bg-gray-700/50
--- a/templates/projects/partials/project_table.html
+++ b/templates/projects/partials/project_table.html
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/partials/project_table.html
+++ b/templates/projects/partials/project_table.html
@@ -0,0 +1,10 @@
1 <div id="project-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <tr>
5 <div id="project-table">
6 <div class="overflow-x-auto rounded-lg border border-gray-700 bg-grayext-gray-400">Visibility</th>
7 <th class="px-6 py-3 text-left textext-gray-400">Teams</th>
8 <th class="px-6 py-3 text-left text-xs font-medium uppercase t bg-gray-800">
9 {% for project in projects %}
10 <tr class="hover:bg-gray-700/50
--- a/templates/projects/partials/project_team_table.html
+++ b/templates/projects/partials/project_team_table.html
@@ -0,0 +1 @@
1
+<div id="project-team-tablhidden
--- a/templates/projects/partials/project_team_table.html
+++ b/templates/projects/partials/project_team_table.html
@@ -0,0 +1 @@
 
--- a/templates/projects/partials/project_team_table.html
+++ b/templates/projects/partials/project_team_table.html
@@ -0,0 +1 @@
1 <div id="project-team-tablhidden
--- a/templates/projects/project_confirm_delete.html
+++ b/templates/projects/project_confirm_delete.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Delete {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.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 Project</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to delete <strong class="text-gray-100">{{ project.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'projects:detail' slug=project.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Delete
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/projects/project_confirm_delete.html
+++ b/templates/projects/project_confirm_delete.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_confirm_delete.html
+++ b/templates/projects/project_confirm_delete.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Delete {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.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 Project</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to delete <strong class="text-gray-100">{{ project.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'projects:detail' slug=project.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Delete
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/projects/project_detail.html
+++ b/templates/projects/project_detail.html
@@ -0,0 +1,8 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% blmb-6">
5
+ <a href="{% url 'projects:list':tickets' slug=proje{% extends "base.html" %}
6
+{% block k title %}{{ project.name }} — Fossilrepo{% endblock %}
7
+
8
+{% bl
--- a/templates/projects/project_detail.html
+++ b/templates/projects/project_detail.html
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
--- a/templates/projects/project_detail.html
+++ b/templates/projects/project_detail.html
@@ -0,0 +1,8 @@
1 {% extends "base.html" %}
2 {% block title %}{{ project.name }} — Fossilrepo{% endblock %}
3
4 {% blmb-6">
5 <a href="{% url 'projects:list':tickets' slug=proje{% extends "base.html" %}
6 {% block k title %}{{ project.name }} — Fossilrepo{% endblock %}
7
8 {% bl
--- a/templates/projects/project_form.html
+++ b/templates/projects/project_form.html
@@ -0,0 +1,33 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Projects</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
+
12
+ <form method="post" class="space-y-6 roundeg bg-gray-800 p-6 shadow-sm border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {ow #}
16
+ {% else %}
17
+ <div>
18
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
19
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
20
+ </label>
21
+ <div class="mt-1">{{ field }}</div>
22
+ {% if field.errors %}
23
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
24
+ {% endfor source: '{{ form.repo_source.value|default:"empty" }}' }">
25
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Repository Source</h3>
26
+ <div class="space-y-3">
27
+ <label class="flex items-center ">
28
+" x-modee="repo_source" value="empty" x-model="source"
29
+ class="text-brand focus:ring-brand">
30
+ <div>
31
+ <span class="text-sm text-gray- </label>
32
+ <label class="flex items-center gap-3 cursor-pointer">
33
+ <input
--- a/templates/projects/project_form.html
+++ b/templates/projects/project_form.html
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_form.html
+++ b/templates/projects/project_form.html
@@ -0,0 +1,33 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Projects</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12 <form method="post" class="space-y-6 roundeg bg-gray-800 p-6 shadow-sm border border-gray-700">
13 {% csrf_token %}
14
15 {ow #}
16 {% else %}
17 <div>
18 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
19 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
20 </label>
21 <div class="mt-1">{{ field }}</div>
22 {% if field.errors %}
23 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
24 {% endfor source: '{{ form.repo_source.value|default:"empty" }}' }">
25 <h3 class="text-sm font-medium text-gray-300 mb-3">Repository Source</h3>
26 <div class="space-y-3">
27 <label class="flex items-center ">
28 " x-modee="repo_source" value="empty" x-model="source"
29 class="text-brand focus:ring-brand">
30 <div>
31 <span class="text-sm text-gray- </label>
32 <label class="flex items-center gap-3 cursor-pointer">
33 <input
--- a/templates/projects/project_list.html
+++ b/templates/projects/project_list.html
@@ -0,0 +1 @@
1
+{% extends "base.html"class="w-full max-w-md /endblock %}
--- a/templates/projects/project_list.html
+++ b/templates/projects/project_list.html
@@ -0,0 +1 @@
 
--- a/templates/projects/project_list.html
+++ b/templates/projects/project_list.html
@@ -0,0 +1 @@
1 {% extends "base.html"class="w-full max-w-md /endblock %}
--- a/templates/projects/project_team_add.html
+++ b/templates/projects/project_team_add.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Add Team to {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Team to {{ project.name }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'projects:detail' slug=project.slug %}"
29
+ 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">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Add Team
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/projects/project_team_add.html
+++ b/templates/projects/project_team_add.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_team_add.html
+++ b/templates/projects/project_team_add.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Add Team to {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Team to {{ project.name }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'projects:detail' slug=project.slug %}"
29 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">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Add Team
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/projects/project_team_confirm_remove.html
+++ b/templates/projects/project_team_confirm_remove.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Remove Team — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.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">Remove Team</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to remove <strong class="text-gray-100">{{ team.name }}</strong> from <strong class="text-gray-100">{{ project.name }}</strong>?
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'projects:detail' slug=project.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Remove
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/projects/project_team_confirm_remove.html
+++ b/templates/projects/project_team_confirm_remove.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_team_confirm_remove.html
+++ b/templates/projects/project_team_confirm_remove.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Remove Team — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.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">Remove Team</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to remove <strong class="text-gray-100">{{ team.name }}</strong> from <strong class="text-gray-100">{{ project.name }}</strong>?
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'projects:detail' slug=project.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Remove
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/projects/project_team_edit.html
+++ b/templates/projects/project_team_edit.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Edit Team Role — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit {{ team.name }} Role on {{ project.name }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'projects:detail' slug=project.slug %}"
29
+ 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">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Update Role
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/projects/project_team_edit.html
+++ b/templates/projects/project_team_edit.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_team_edit.html
+++ b/templates/projects/project_team_edit.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Edit Team Role — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit {{ team.name }} Role on {{ project.name }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'projects:detail' slug=project.slug %}"
29 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">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Update Role
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- testdata/management/commands/seed.py
+++ testdata/management/commands/seed.py
@@ -2,11 +2,13 @@
22
33
from django.contrib.auth.models import Group, Permission, User
44
from django.core.management.base import BaseCommand
55
66
from items.models import Item
7
-from organization.models import Organization, OrganizationMember
7
+from organization.models import Organization, OrganizationMember, Team
8
+from pages.models import Page
9
+from projects.models import Project, ProjectTeam
810
911
logger = logging.getLogger(__name__)
1012
1113
1214
class Command(BaseCommand):
@@ -15,26 +17,34 @@
1517
def add_arguments(self, parser):
1618
parser.add_argument("--flush", action="store_true", help="Flush non-system tables before seeding.")
1719
1820
def handle(self, *args, **options):
1921
if options["flush"]:
20
- self.stdout.write("Flushing item and organization data...")
22
+ self.stdout.write("Flushing data...")
23
+ Page.all_objects.all().delete()
24
+ ProjectTeam.all_objects.all().delete()
25
+ Project.all_objects.all().delete()
2126
Item.all_objects.all().delete()
27
+ Team.all_objects.all().delete()
2228
OrganizationMember.all_objects.all().delete()
2329
Organization.all_objects.all().delete()
2430
2531
# Groups and permissions
2632
admin_group, _ = Group.objects.get_or_create(name="Administrators")
2733
viewer_group, _ = Group.objects.get_or_create(name="Viewers")
2834
29
- item_perms = Permission.objects.filter(content_type__app_label="items")
30
- admin_group.permissions.set(item_perms)
31
- view_perms = item_perms.filter(codename__startswith="view_")
32
- viewer_group.permissions.set(view_perms)
35
+ # Admin group gets all permissions for items, org, and projects
36
+ for app_label in ["items", "organization", "projects", "pages"]:
37
+ perms = Permission.objects.filter(content_type__app_label=app_label)
38
+ admin_group.permissions.add(*perms)
3339
34
- org_perms = Permission.objects.filter(content_type__app_label="organization")
35
- admin_group.permissions.add(*org_perms)
40
+ # Viewer group gets view permissions for items, org, and projects
41
+ view_perms = Permission.objects.filter(
42
+ content_type__app_label__in=["items", "organization", "projects", "pages"],
43
+ codename__startswith="view_",
44
+ )
45
+ viewer_group.permissions.set(view_perms)
3646
3747
# Superuser
3848
admin_user, created = User.objects.get_or_create(
3949
username="admin",
4050
defaults={"email": "[email protected]", "is_staff": True, "is_superuser": True},
@@ -58,10 +68,50 @@
5868
# Organization
5969
org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"})
6070
OrganizationMember.objects.get_or_create(member=admin_user, organization=org)
6171
OrganizationMember.objects.get_or_create(member=viewer_user, organization=org)
6272
73
+ # Teams
74
+ core_devs, _ = Team.objects.get_or_create(name="Core Devs", defaults={"organization": org, "description": "Core development team"})
75
+ core_devs.members.add(admin_user)
76
+
77
+ contributors, _ = Team.objects.get_or_create(
78
+ name="Contributors", defaults={"organization": org, "description": "Community contributors"}
79
+ )
80
+ contributors.members.add(viewer_user)
81
+
82
+ reviewers, _ = Team.objects.get_or_create(name="Reviewers", defaults={"organization": org, "description": "Code review team"})
83
+ reviewers.members.add(admin_user, viewer_user)
84
+
85
+ # Projects
86
+ projects_data = [
87
+ {"name": "Frontend App", "description": "User-facing web application", "visibility": "internal"},
88
+ {"name": "Backend API", "description": "Core API service", "visibility": "private"},
89
+ {"name": "Documentation", "description": "Project documentation and guides", "visibility": "public"},
90
+ {"name": "Infrastructure", "description": "Deployment and infrastructure tooling", "visibility": "private"},
91
+ ]
92
+ for pdata in projects_data:
93
+ project, _ = Project.objects.get_or_create(
94
+ name=pdata["name"],
95
+ defaults={**pdata, "organization": org, "created_by": admin_user},
96
+ )
97
+
98
+ # Team-project assignments
99
+ frontend = Project.objects.filter(name="Frontend App").first()
100
+ backend = Project.objects.filter(name="Backend API").first()
101
+ docs = Project.objects.filter(name="Documentation").first()
102
+
103
+ if frontend:
104
+ ProjectTeam.objects.get_or_create(project=frontend, team=core_devs, defaults={"role": "admin"})
105
+ ProjectTeam.objects.get_or_create(project=frontend, team=contributors, defaults={"role": "write"})
106
+ if backend:
107
+ ProjectTeam.objects.get_or_create(project=backend, team=core_devs, defaults={"role": "admin"})
108
+ ProjectTeam.objects.get_or_create(project=backend, team=reviewers, defaults={"role": "read"})
109
+ if docs:
110
+ ProjectTeam.objects.get_or_create(project=docs, team=contributors, defaults={"role": "write"})
111
+ ProjectTeam.objects.get_or_create(project=docs, team=reviewers, defaults={"role": "write"})
112
+
63113
# Sample items
64114
items_data = [
65115
{"name": "Widget Alpha", "price": "29.99", "sku": "WGT-001", "description": "A versatile alpha widget."},
66116
{"name": "Widget Beta", "price": "49.99", "sku": "WGT-002", "description": "Enhanced beta widget with extra features."},
67117
{"name": "Gadget Pro", "price": "199.99", "sku": "GDG-001", "description": "Professional-grade gadget."},
@@ -71,7 +121,28 @@
71121
for data in items_data:
72122
Item.objects.get_or_create(
73123
sku=data["sku"],
74124
defaults={**data, "created_by": admin_user},
75125
)
126
+
127
+ # Sample docs pages
128
+ pages_data = [
129
+ {
130
+ "name": "Getting Started",
131
+ "content": "# Getting Started\n\nWelcome to Fossilrepo. This guide covers initial setup and configuration.\n\n## Prerequisites\n\n- Docker and Docker Compose\n- A domain name (for SSL)\n- S3-compatible storage (for backups)\n\n## Quick Start\n\n1. Clone the repository\n2. Copy `.env.example` to `.env`\n3. Run `fossilrepo-ctl reconfigure`\n4. Run `fossilrepo-ctl start`\n",
132
+ },
133
+ {
134
+ "name": "Admin Guide",
135
+ "content": "# Admin Guide\n\nThis guide covers day-to-day administration of your Fossilrepo instance.\n\n## Managing Users\n\nUsers can be added through the Django admin or the Settings > Members page.\n\n## Backups\n\nLitestream continuously replicates all `.fossil` files to S3. Manual backups can be created with `fossilrepo-ctl backup create`.\n\n## Monitoring\n\nCheck `/health/` for service status and `/status/` for an overview page.\n",
136
+ },
137
+ {
138
+ "name": "Architecture Overview",
139
+ "content": "# Architecture Overview\n\n## Stack\n\n| Component | Technology |\n|-----------|------------|\n| Backend | Django 5 + HTMX |\n| Database | PostgreSQL 16 |\n| SCM | Fossil |\n| Proxy | Caddy |\n| Backups | Litestream → S3 |\n| Jobs | Celery + Redis |\n\n## How It Works\n\nEach Fossil repository is a single `.fossil` SQLite file. Caddy routes subdomain requests to the Fossil server. Django provides the management UI. Litestream continuously replicates repo files to S3.\n",
140
+ },
141
+ ]
142
+ for pdata in pages_data:
143
+ Page.objects.get_or_create(
144
+ name=pdata["name"],
145
+ defaults={**pdata, "organization": org, "created_by": admin_user},
146
+ )
76147
77148
self.stdout.write(self.style.SUCCESS("Seed complete."))
78149
79150
ADDED tests/__init__.py
--- testdata/management/commands/seed.py
+++ testdata/management/commands/seed.py
@@ -2,11 +2,13 @@
2
3 from django.contrib.auth.models import Group, Permission, User
4 from django.core.management.base import BaseCommand
5
6 from items.models import Item
7 from organization.models import Organization, OrganizationMember
 
 
8
9 logger = logging.getLogger(__name__)
10
11
12 class Command(BaseCommand):
@@ -15,26 +17,34 @@
15 def add_arguments(self, parser):
16 parser.add_argument("--flush", action="store_true", help="Flush non-system tables before seeding.")
17
18 def handle(self, *args, **options):
19 if options["flush"]:
20 self.stdout.write("Flushing item and organization data...")
 
 
 
21 Item.all_objects.all().delete()
 
22 OrganizationMember.all_objects.all().delete()
23 Organization.all_objects.all().delete()
24
25 # Groups and permissions
26 admin_group, _ = Group.objects.get_or_create(name="Administrators")
27 viewer_group, _ = Group.objects.get_or_create(name="Viewers")
28
29 item_perms = Permission.objects.filter(content_type__app_label="items")
30 admin_group.permissions.set(item_perms)
31 view_perms = item_perms.filter(codename__startswith="view_")
32 viewer_group.permissions.set(view_perms)
33
34 org_perms = Permission.objects.filter(content_type__app_label="organization")
35 admin_group.permissions.add(*org_perms)
 
 
 
 
36
37 # Superuser
38 admin_user, created = User.objects.get_or_create(
39 username="admin",
40 defaults={"email": "[email protected]", "is_staff": True, "is_superuser": True},
@@ -58,10 +68,50 @@
58 # Organization
59 org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"})
60 OrganizationMember.objects.get_or_create(member=admin_user, organization=org)
61 OrganizationMember.objects.get_or_create(member=viewer_user, organization=org)
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63 # Sample items
64 items_data = [
65 {"name": "Widget Alpha", "price": "29.99", "sku": "WGT-001", "description": "A versatile alpha widget."},
66 {"name": "Widget Beta", "price": "49.99", "sku": "WGT-002", "description": "Enhanced beta widget with extra features."},
67 {"name": "Gadget Pro", "price": "199.99", "sku": "GDG-001", "description": "Professional-grade gadget."},
@@ -71,7 +121,28 @@
71 for data in items_data:
72 Item.objects.get_or_create(
73 sku=data["sku"],
74 defaults={**data, "created_by": admin_user},
75 )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
77 self.stdout.write(self.style.SUCCESS("Seed complete."))
78
79 DDED tests/__init__.py
--- testdata/management/commands/seed.py
+++ testdata/management/commands/seed.py
@@ -2,11 +2,13 @@
2
3 from django.contrib.auth.models import Group, Permission, User
4 from django.core.management.base import BaseCommand
5
6 from items.models import Item
7 from organization.models import Organization, OrganizationMember, Team
8 from pages.models import Page
9 from projects.models import Project, ProjectTeam
10
11 logger = logging.getLogger(__name__)
12
13
14 class Command(BaseCommand):
@@ -15,26 +17,34 @@
17 def add_arguments(self, parser):
18 parser.add_argument("--flush", action="store_true", help="Flush non-system tables before seeding.")
19
20 def handle(self, *args, **options):
21 if options["flush"]:
22 self.stdout.write("Flushing data...")
23 Page.all_objects.all().delete()
24 ProjectTeam.all_objects.all().delete()
25 Project.all_objects.all().delete()
26 Item.all_objects.all().delete()
27 Team.all_objects.all().delete()
28 OrganizationMember.all_objects.all().delete()
29 Organization.all_objects.all().delete()
30
31 # Groups and permissions
32 admin_group, _ = Group.objects.get_or_create(name="Administrators")
33 viewer_group, _ = Group.objects.get_or_create(name="Viewers")
34
35 # Admin group gets all permissions for items, org, and projects
36 for app_label in ["items", "organization", "projects", "pages"]:
37 perms = Permission.objects.filter(content_type__app_label=app_label)
38 admin_group.permissions.add(*perms)
39
40 # Viewer group gets view permissions for items, org, and projects
41 view_perms = Permission.objects.filter(
42 content_type__app_label__in=["items", "organization", "projects", "pages"],
43 codename__startswith="view_",
44 )
45 viewer_group.permissions.set(view_perms)
46
47 # Superuser
48 admin_user, created = User.objects.get_or_create(
49 username="admin",
50 defaults={"email": "[email protected]", "is_staff": True, "is_superuser": True},
@@ -58,10 +68,50 @@
68 # Organization
69 org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"})
70 OrganizationMember.objects.get_or_create(member=admin_user, organization=org)
71 OrganizationMember.objects.get_or_create(member=viewer_user, organization=org)
72
73 # Teams
74 core_devs, _ = Team.objects.get_or_create(name="Core Devs", defaults={"organization": org, "description": "Core development team"})
75 core_devs.members.add(admin_user)
76
77 contributors, _ = Team.objects.get_or_create(
78 name="Contributors", defaults={"organization": org, "description": "Community contributors"}
79 )
80 contributors.members.add(viewer_user)
81
82 reviewers, _ = Team.objects.get_or_create(name="Reviewers", defaults={"organization": org, "description": "Code review team"})
83 reviewers.members.add(admin_user, viewer_user)
84
85 # Projects
86 projects_data = [
87 {"name": "Frontend App", "description": "User-facing web application", "visibility": "internal"},
88 {"name": "Backend API", "description": "Core API service", "visibility": "private"},
89 {"name": "Documentation", "description": "Project documentation and guides", "visibility": "public"},
90 {"name": "Infrastructure", "description": "Deployment and infrastructure tooling", "visibility": "private"},
91 ]
92 for pdata in projects_data:
93 project, _ = Project.objects.get_or_create(
94 name=pdata["name"],
95 defaults={**pdata, "organization": org, "created_by": admin_user},
96 )
97
98 # Team-project assignments
99 frontend = Project.objects.filter(name="Frontend App").first()
100 backend = Project.objects.filter(name="Backend API").first()
101 docs = Project.objects.filter(name="Documentation").first()
102
103 if frontend:
104 ProjectTeam.objects.get_or_create(project=frontend, team=core_devs, defaults={"role": "admin"})
105 ProjectTeam.objects.get_or_create(project=frontend, team=contributors, defaults={"role": "write"})
106 if backend:
107 ProjectTeam.objects.get_or_create(project=backend, team=core_devs, defaults={"role": "admin"})
108 ProjectTeam.objects.get_or_create(project=backend, team=reviewers, defaults={"role": "read"})
109 if docs:
110 ProjectTeam.objects.get_or_create(project=docs, team=contributors, defaults={"role": "write"})
111 ProjectTeam.objects.get_or_create(project=docs, team=reviewers, defaults={"role": "write"})
112
113 # Sample items
114 items_data = [
115 {"name": "Widget Alpha", "price": "29.99", "sku": "WGT-001", "description": "A versatile alpha widget."},
116 {"name": "Widget Beta", "price": "49.99", "sku": "WGT-002", "description": "Enhanced beta widget with extra features."},
117 {"name": "Gadget Pro", "price": "199.99", "sku": "GDG-001", "description": "Professional-grade gadget."},
@@ -71,7 +121,28 @@
121 for data in items_data:
122 Item.objects.get_or_create(
123 sku=data["sku"],
124 defaults={**data, "created_by": admin_user},
125 )
126
127 # Sample docs pages
128 pages_data = [
129 {
130 "name": "Getting Started",
131 "content": "# Getting Started\n\nWelcome to Fossilrepo. This guide covers initial setup and configuration.\n\n## Prerequisites\n\n- Docker and Docker Compose\n- A domain name (for SSL)\n- S3-compatible storage (for backups)\n\n## Quick Start\n\n1. Clone the repository\n2. Copy `.env.example` to `.env`\n3. Run `fossilrepo-ctl reconfigure`\n4. Run `fossilrepo-ctl start`\n",
132 },
133 {
134 "name": "Admin Guide",
135 "content": "# Admin Guide\n\nThis guide covers day-to-day administration of your Fossilrepo instance.\n\n## Managing Users\n\nUsers can be added through the Django admin or the Settings > Members page.\n\n## Backups\n\nLitestream continuously replicates all `.fossil` files to S3. Manual backups can be created with `fossilrepo-ctl backup create`.\n\n## Monitoring\n\nCheck `/health/` for service status and `/status/` for an overview page.\n",
136 },
137 {
138 "name": "Architecture Overview",
139 "content": "# Architecture Overview\n\n## Stack\n\n| Component | Technology |\n|-----------|------------|\n| Backend | Django 5 + HTMX |\n| Database | PostgreSQL 16 |\n| SCM | Fossil |\n| Proxy | Caddy |\n| Backups | Litestream → S3 |\n| Jobs | Celery + Redis |\n\n## How It Works\n\nEach Fossil repository is a single `.fossil` SQLite file. Caddy routes subdomain requests to the Fossil server. Django provides the management UI. Litestream continuously replicates repo files to S3.\n",
140 },
141 ]
142 for pdata in pages_data:
143 Page.objects.get_or_create(
144 name=pdata["name"],
145 defaults={**pdata, "organization": org, "created_by": admin_user},
146 )
147
148 self.stdout.write(self.style.SUCCESS("Seed complete."))
149
150 DDED tests/__init__.py

No diff available

+81 -66
--- uv.lock
+++ uv.lock
@@ -30,76 +30,10 @@
3030
sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
3131
wheels = [
3232
{ url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
3333
]
3434
35
-[[package]]
36
-name = "boilerworks-django-htmx"
37
-version = "0.1.0"
38
-source = { editable = "." }
39
-dependencies = [
40
- { name = "boto3" },
41
- { name = "celery", extra = ["redis"] },
42
- { name = "django" },
43
- { name = "django-celery-beat" },
44
- { name = "django-celery-results" },
45
- { name = "django-constance" },
46
- { name = "django-cors-headers" },
47
- { name = "django-health-check" },
48
- { name = "django-import-export" },
49
- { name = "django-ratelimit" },
50
- { name = "django-ses" },
51
- { name = "django-simple-history" },
52
- { name = "django-storages", extra = ["s3"] },
53
- { name = "gunicorn" },
54
- { name = "psycopg2-binary" },
55
- { name = "redis" },
56
- { name = "sentry-sdk", extra = ["django"] },
57
- { name = "whitenoise" },
58
-]
59
-
60
-[package.optional-dependencies]
61
-dev = [
62
- { name = "coverage" },
63
- { name = "freezegun" },
64
- { name = "pip-audit" },
65
- { name = "pytest" },
66
- { name = "pytest-cov" },
67
- { name = "pytest-django" },
68
- { name = "ruff" },
69
-]
70
-
71
-[package.metadata]
72
-requires-dist = [
73
- { name = "boto3", specifier = ">=1.35" },
74
- { name = "celery", extras = ["redis"], specifier = ">=5.4" },
75
- { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
76
- { name = "django", specifier = ">=5.1,<6.0" },
77
- { name = "django-celery-beat", specifier = ">=2.7" },
78
- { name = "django-celery-results", specifier = ">=2.5" },
79
- { name = "django-constance", extras = ["database"], specifier = ">=4.1" },
80
- { name = "django-cors-headers", specifier = ">=4.4" },
81
- { name = "django-health-check", specifier = ">=3.18" },
82
- { name = "django-import-export", specifier = ">=4.0" },
83
- { name = "django-ratelimit", specifier = ">=4.1" },
84
- { name = "django-ses", specifier = ">=4.1" },
85
- { name = "django-simple-history", specifier = ">=3.7" },
86
- { name = "django-storages", extras = ["s3"], specifier = ">=1.14" },
87
- { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" },
88
- { name = "gunicorn", specifier = ">=23.0" },
89
- { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" },
90
- { name = "psycopg2-binary", specifier = ">=2.9" },
91
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
92
- { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
93
- { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" },
94
- { name = "redis", specifier = ">=5.0" },
95
- { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" },
96
- { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" },
97
- { name = "whitenoise", specifier = ">=6.7" },
98
-]
99
-provides-extras = ["dev"]
100
-
10135
[[package]]
10236
name = "boolean-py"
10337
version = "5.0"
10438
source = { registry = "https://pypi.org/simple" }
10539
sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
@@ -617,10 +551,82 @@
617551
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
618552
wheels = [
619553
{ url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
620554
]
621555
556
+[[package]]
557
+name = "fossilrepo"
558
+version = "0.1.0"
559
+source = { editable = "." }
560
+dependencies = [
561
+ { name = "boto3" },
562
+ { name = "celery", extra = ["redis"] },
563
+ { name = "click" },
564
+ { name = "django" },
565
+ { name = "django-celery-beat" },
566
+ { name = "django-celery-results" },
567
+ { name = "django-constance" },
568
+ { name = "django-cors-headers" },
569
+ { name = "django-health-check" },
570
+ { name = "django-import-export" },
571
+ { name = "django-ratelimit" },
572
+ { name = "django-ses" },
573
+ { name = "django-simple-history" },
574
+ { name = "django-storages", extra = ["s3"] },
575
+ { name = "gunicorn" },
576
+ { name = "markdown" },
577
+ { name = "psycopg2-binary" },
578
+ { name = "redis" },
579
+ { name = "rich" },
580
+ { name = "sentry-sdk", extra = ["django"] },
581
+ { name = "whitenoise" },
582
+]
583
+
584
+[package.optional-dependencies]
585
+dev = [
586
+ { name = "coverage" },
587
+ { name = "freezegun" },
588
+ { name = "pip-audit" },
589
+ { name = "pytest" },
590
+ { name = "pytest-cov" },
591
+ { name = "pytest-django" },
592
+ { name = "ruff" },
593
+]
594
+
595
+[package.metadata]
596
+requires-dist = [
597
+ { name = "boto3", specifier = ">=1.35" },
598
+ { name = "celery", extras = ["redis"], specifier = ">=5.4" },
599
+ { name = "click", specifier = ">=8.1" },
600
+ { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
601
+ { name = "django", specifier = ">=5.1,<6.0" },
602
+ { name = "django-celery-beat", specifier = ">=2.7" },
603
+ { name = "django-celery-results", specifier = ">=2.5" },
604
+ { name = "django-constance", extras = ["database"], specifier = ">=4.1" },
605
+ { name = "django-cors-headers", specifier = ">=4.4" },
606
+ { name = "django-health-check", specifier = ">=3.18" },
607
+ { name = "django-import-export", specifier = ">=4.0" },
608
+ { name = "django-ratelimit", specifier = ">=4.1" },
609
+ { name = "django-ses", specifier = ">=4.1" },
610
+ { name = "django-simple-history", specifier = ">=3.7" },
611
+ { name = "django-storages", extras = ["s3"], specifier = ">=1.14" },
612
+ { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" },
613
+ { name = "gunicorn", specifier = ">=23.0" },
614
+ { name = "markdown", specifier = ">=3.6" },
615
+ { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" },
616
+ { name = "psycopg2-binary", specifier = ">=2.9" },
617
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
618
+ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
619
+ { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" },
620
+ { name = "redis", specifier = ">=5.0" },
621
+ { name = "rich", specifier = ">=13.0" },
622
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" },
623
+ { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" },
624
+ { name = "whitenoise", specifier = ">=6.7" },
625
+]
626
+provides-extras = ["dev"]
627
+
622628
[[package]]
623629
name = "freezegun"
624630
version = "1.5.5"
625631
source = { registry = "https://pypi.org/simple" }
626632
dependencies = [
@@ -699,10 +705,19 @@
699705
]
700706
sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
701707
wheels = [
702708
{ url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
703709
]
710
+
711
+[[package]]
712
+name = "markdown"
713
+version = "3.10.2"
714
+source = { registry = "https://pypi.org/simple" }
715
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
716
+wheels = [
717
+ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
718
+]
704719
705720
[[package]]
706721
name = "markdown-it-py"
707722
version = "4.0.0"
708723
source = { registry = "https://pypi.org/simple" }
709724
--- uv.lock
+++ uv.lock
@@ -30,76 +30,10 @@
30 sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
31 wheels = [
32 { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
33 ]
34
35 [[package]]
36 name = "boilerworks-django-htmx"
37 version = "0.1.0"
38 source = { editable = "." }
39 dependencies = [
40 { name = "boto3" },
41 { name = "celery", extra = ["redis"] },
42 { name = "django" },
43 { name = "django-celery-beat" },
44 { name = "django-celery-results" },
45 { name = "django-constance" },
46 { name = "django-cors-headers" },
47 { name = "django-health-check" },
48 { name = "django-import-export" },
49 { name = "django-ratelimit" },
50 { name = "django-ses" },
51 { name = "django-simple-history" },
52 { name = "django-storages", extra = ["s3"] },
53 { name = "gunicorn" },
54 { name = "psycopg2-binary" },
55 { name = "redis" },
56 { name = "sentry-sdk", extra = ["django"] },
57 { name = "whitenoise" },
58 ]
59
60 [package.optional-dependencies]
61 dev = [
62 { name = "coverage" },
63 { name = "freezegun" },
64 { name = "pip-audit" },
65 { name = "pytest" },
66 { name = "pytest-cov" },
67 { name = "pytest-django" },
68 { name = "ruff" },
69 ]
70
71 [package.metadata]
72 requires-dist = [
73 { name = "boto3", specifier = ">=1.35" },
74 { name = "celery", extras = ["redis"], specifier = ">=5.4" },
75 { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
76 { name = "django", specifier = ">=5.1,<6.0" },
77 { name = "django-celery-beat", specifier = ">=2.7" },
78 { name = "django-celery-results", specifier = ">=2.5" },
79 { name = "django-constance", extras = ["database"], specifier = ">=4.1" },
80 { name = "django-cors-headers", specifier = ">=4.4" },
81 { name = "django-health-check", specifier = ">=3.18" },
82 { name = "django-import-export", specifier = ">=4.0" },
83 { name = "django-ratelimit", specifier = ">=4.1" },
84 { name = "django-ses", specifier = ">=4.1" },
85 { name = "django-simple-history", specifier = ">=3.7" },
86 { name = "django-storages", extras = ["s3"], specifier = ">=1.14" },
87 { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" },
88 { name = "gunicorn", specifier = ">=23.0" },
89 { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" },
90 { name = "psycopg2-binary", specifier = ">=2.9" },
91 { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
92 { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
93 { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" },
94 { name = "redis", specifier = ">=5.0" },
95 { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" },
96 { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" },
97 { name = "whitenoise", specifier = ">=6.7" },
98 ]
99 provides-extras = ["dev"]
100
101 [[package]]
102 name = "boolean-py"
103 version = "5.0"
104 source = { registry = "https://pypi.org/simple" }
105 sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
@@ -617,10 +551,82 @@
617 sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
618 wheels = [
619 { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
620 ]
621
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622 [[package]]
623 name = "freezegun"
624 version = "1.5.5"
625 source = { registry = "https://pypi.org/simple" }
626 dependencies = [
@@ -699,10 +705,19 @@
699 ]
700 sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
701 wheels = [
702 { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
703 ]
 
 
 
 
 
 
 
 
 
704
705 [[package]]
706 name = "markdown-it-py"
707 version = "4.0.0"
708 source = { registry = "https://pypi.org/simple" }
709
--- uv.lock
+++ uv.lock
@@ -30,76 +30,10 @@
30 sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
31 wheels = [
32 { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
33 ]
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35 [[package]]
36 name = "boolean-py"
37 version = "5.0"
38 source = { registry = "https://pypi.org/simple" }
39 sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
@@ -617,10 +551,82 @@
551 sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
552 wheels = [
553 { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
554 ]
555
556 [[package]]
557 name = "fossilrepo"
558 version = "0.1.0"
559 source = { editable = "." }
560 dependencies = [
561 { name = "boto3" },
562 { name = "celery", extra = ["redis"] },
563 { name = "click" },
564 { name = "django" },
565 { name = "django-celery-beat" },
566 { name = "django-celery-results" },
567 { name = "django-constance" },
568 { name = "django-cors-headers" },
569 { name = "django-health-check" },
570 { name = "django-import-export" },
571 { name = "django-ratelimit" },
572 { name = "django-ses" },
573 { name = "django-simple-history" },
574 { name = "django-storages", extra = ["s3"] },
575 { name = "gunicorn" },
576 { name = "markdown" },
577 { name = "psycopg2-binary" },
578 { name = "redis" },
579 { name = "rich" },
580 { name = "sentry-sdk", extra = ["django"] },
581 { name = "whitenoise" },
582 ]
583
584 [package.optional-dependencies]
585 dev = [
586 { name = "coverage" },
587 { name = "freezegun" },
588 { name = "pip-audit" },
589 { name = "pytest" },
590 { name = "pytest-cov" },
591 { name = "pytest-django" },
592 { name = "ruff" },
593 ]
594
595 [package.metadata]
596 requires-dist = [
597 { name = "boto3", specifier = ">=1.35" },
598 { name = "celery", extras = ["redis"], specifier = ">=5.4" },
599 { name = "click", specifier = ">=8.1" },
600 { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
601 { name = "django", specifier = ">=5.1,<6.0" },
602 { name = "django-celery-beat", specifier = ">=2.7" },
603 { name = "django-celery-results", specifier = ">=2.5" },
604 { name = "django-constance", extras = ["database"], specifier = ">=4.1" },
605 { name = "django-cors-headers", specifier = ">=4.4" },
606 { name = "django-health-check", specifier = ">=3.18" },
607 { name = "django-import-export", specifier = ">=4.0" },
608 { name = "django-ratelimit", specifier = ">=4.1" },
609 { name = "django-ses", specifier = ">=4.1" },
610 { name = "django-simple-history", specifier = ">=3.7" },
611 { name = "django-storages", extras = ["s3"], specifier = ">=1.14" },
612 { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" },
613 { name = "gunicorn", specifier = ">=23.0" },
614 { name = "markdown", specifier = ">=3.6" },
615 { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" },
616 { name = "psycopg2-binary", specifier = ">=2.9" },
617 { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
618 { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
619 { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" },
620 { name = "redis", specifier = ">=5.0" },
621 { name = "rich", specifier = ">=13.0" },
622 { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" },
623 { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" },
624 { name = "whitenoise", specifier = ">=6.7" },
625 ]
626 provides-extras = ["dev"]
627
628 [[package]]
629 name = "freezegun"
630 version = "1.5.5"
631 source = { registry = "https://pypi.org/simple" }
632 dependencies = [
@@ -699,10 +705,19 @@
705 ]
706 sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
707 wheels = [
708 { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
709 ]
710
711 [[package]]
712 name = "markdown"
713 version = "3.10.2"
714 source = { registry = "https://pypi.org/simple" }
715 sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
716 wheels = [
717 { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
718 ]
719
720 [[package]]
721 name = "markdown-it-py"
722 version = "4.0.0"
723 source = { registry = "https://pypi.org/simple" }
724

Keyboard Shortcuts

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