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 UTC 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 +6 +30 +1 +23 +81 +32 +18 +29 +65 +25 +9 +36 +363 +1 +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 +13 +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,6 @@
1
+}
2
+
3
+ }
4
+
5
+return {pages": pages,
6
+ }
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -0,0 +1,6 @@
1 }
2
3 }
4
5 return {pages": pages,
6 }
--- 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 @@
1
+syncpstream remote URL for syncl_path(self)
--- a/fossil/models.py
+++ b/fossil/models.py
@@ -0,0 +1 @@
 
--- a/fossil/models.py
+++ b/fossil/models.py
@@ -0,0 +1 @@
1 syncpstream remote URL for syncl_path(self)
--- 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