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
46d058f09c666a83aee85a6c35c2295dd9d8e79f49c7297c8e1b2b06a0be1a41
| --- 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 |
| --- CLAUDE.md | ||
| +++ CLAUDE.md | ||
| @@ -1,11 +1,15 @@ | ||
| 1 | -# Claude -- Fossilrepo Django + HTMX | |
| 1 | +# Claude -- fossilrepo | |
| 2 | 2 | |
| 3 | 3 | Primary conventions doc: [`bootstrap.md`](bootstrap.md) |
| 4 | 4 | |
| 5 | 5 | Read it before writing any code. |
| 6 | 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 | + | |
| 7 | 11 | ## Stack |
| 8 | 12 | |
| 9 | 13 | - **Backend**: Django 5 (Python 3.12+) |
| 10 | 14 | - **Frontend**: HTMX 2.0 + Alpine.js 3 + Tailwind CSS (CDN) |
| 11 | 15 | - **API**: Django views returning HTML (full pages + HTMX partials) |
| @@ -13,16 +17,38 @@ | ||
| 13 | 17 | - **Auth**: Session-based (Django native, httpOnly cookies) |
| 14 | 18 | - **Permissions**: Group-based via `P` enum (`core/permissions.py`) |
| 15 | 19 | - **Jobs**: Celery + Redis |
| 16 | 20 | - **Database**: PostgreSQL 16 |
| 17 | 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 | +``` | |
| 18 | 43 | |
| 19 | 44 | ## Claude-specific notes |
| 20 | 45 | |
| 21 | 46 | - Prefer `Edit` over rewriting whole files. |
| 22 | 47 | - 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)`. | |
| 25 | 50 | - Soft-delete only: call `item.soft_delete(user=request.user)`, never `.delete()`. |
| 26 | 51 | - HTMX partials: check `request.headers.get("HX-Request")` to return partial vs full page. |
| 27 | 52 | - CSRF: HTMX requests include CSRF token via `htmx:configRequest` event in `base.html`. |
| 28 | 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. | |
| 29 | 55 |
| --- 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 |
| --- 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 | + | |
| 1 | 26 | FROM python:3.12-slim-bookworm |
| 2 | 27 | |
| 3 | 28 | 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 | |
| 6 | 35 | |
| 7 | 36 | RUN pip install --no-cache-dir uv |
| 8 | 37 | |
| 9 | 38 | WORKDIR /app |
| 10 | 39 | |
| @@ -12,13 +41,16 @@ | ||
| 12 | 41 | RUN uv pip install --system --no-cache -r pyproject.toml |
| 13 | 42 | |
| 14 | 43 | COPY . . |
| 15 | 44 | |
| 16 | 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 | |
| 17 | 49 | |
| 18 | 50 | ENV PYTHONUNBUFFERED=1 |
| 19 | 51 | ENV PYTHONDONTWRITEBYTECODE=1 |
| 20 | 52 | ENV DJANGO_SETTINGS_MODULE=config.settings |
| 21 | 53 | |
| 22 | 54 | EXPOSE 8000 |
| 23 | 55 | |
| 24 | 56 | CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"] |
| 25 | 57 | |
| 26 | 58 | ADDED _old_CLAUDE.md |
| 27 | 59 | ADDED _old_bootstrap.md |
| 28 | 60 | ADDED _old_fossilrepo/__init__.py |
| 29 | 61 | ADDED _old_fossilrepo/cli/__init__.py |
| 30 | 62 | ADDED _old_fossilrepo/cli/main.py |
| 31 | 63 | ADDED _old_fossilrepo/server/__init__.py |
| 32 | 64 | ADDED _old_fossilrepo/server/config.py |
| 33 | 65 | ADDED _old_fossilrepo/server/manager.py |
| 34 | 66 | ADDED _old_fossilrepo/sync/__init__.py |
| 35 | 67 | ADDED _old_fossilrepo/sync/mappings.py |
| 36 | 68 | 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 |
No diff available
| --- 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 |
| --- auth1/forms.py | ||
| +++ auth1/forms.py | ||
| @@ -4,19 +4,19 @@ | ||
| 4 | 4 | |
| 5 | 5 | class LoginForm(AuthenticationForm): |
| 6 | 6 | username = forms.CharField( |
| 7 | 7 | widget=forms.TextInput( |
| 8 | 8 | 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", | |
| 10 | 10 | "placeholder": "Username", |
| 11 | 11 | "autofocus": True, |
| 12 | 12 | } |
| 13 | 13 | ) |
| 14 | 14 | ) |
| 15 | 15 | password = forms.CharField( |
| 16 | 16 | widget=forms.PasswordInput( |
| 17 | 17 | 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", | |
| 19 | 19 | "placeholder": "Password", |
| 20 | 20 | } |
| 21 | 21 | ) |
| 22 | 22 | ) |
| 23 | 23 | |
| 24 | 24 | 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: {} |
| --- bootstrap.md | ||
| +++ bootstrap.md | ||
| @@ -1,10 +1,91 @@ | ||
| 1 | -# Fossilrepo Django + HTMX -- Bootstrap | |
| 1 | +# fossilrepo -- bootstrap | |
| 2 | 2 | |
| 3 | 3 | This is the primary conventions document. All agent shims (`CLAUDE.md`, `AGENTS.md`) point here. |
| 4 | 4 | |
| 5 | 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 | +``` | |
| 6 | 87 | |
| 7 | 88 | --- |
| 8 | 89 | |
| 9 | 90 | ## What's Already Built |
| 10 | 91 | |
| @@ -29,11 +110,11 @@ | ||
| 29 | 110 | |---|---| |
| 30 | 111 | | `config` | Django settings, URLs, Celery configuration | |
| 31 | 112 | | `core` | Base models (Tracking, BaseCoreModel), admin (BaseCoreAdmin), permissions (P enum), middleware | |
| 32 | 113 | | `auth1` | Session-based authentication: login/logout views with rate limiting | |
| 33 | 114 | | `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) | | |
| 35 | 116 | | `testdata` | `seed` management command for development data | |
| 36 | 117 | |
| 37 | 118 | --- |
| 38 | 119 | |
| 39 | 120 | ## Conventions |
| @@ -40,20 +121,20 @@ | ||
| 40 | 121 | |
| 41 | 122 | ### Models |
| 42 | 123 | |
| 43 | 124 | All business models inherit from one of: |
| 44 | 125 | |
| 45 | -**`Tracking`** (abstract) — audit trails: | |
| 126 | +**`Tracking`** (abstract) -- audit trails: | |
| 46 | 127 | ```python |
| 47 | 128 | from core.models import Tracking |
| 48 | 129 | |
| 49 | 130 | class Invoice(Tracking): |
| 50 | 131 | amount = models.DecimalField(...) |
| 51 | 132 | ``` |
| 52 | 133 | Provides: `version` (auto-increments), `created_at/by`, `updated_at/by`, `deleted_at/by`, `history` (simple_history). |
| 53 | 134 | |
| 54 | -**`BaseCoreModel(Tracking)`** (abstract) — named entities: | |
| 135 | +**`BaseCoreModel(Tracking)`** (abstract) -- named entities: | |
| 55 | 136 | ```python |
| 56 | 137 | from core.models import BaseCoreModel |
| 57 | 138 | |
| 58 | 139 | class Item(BaseCoreModel): |
| 59 | 140 | price = models.DecimalField(...) |
| @@ -131,13 +212,13 @@ | ||
| 131 | 212 | |
| 132 | 213 | --- |
| 133 | 214 | |
| 134 | 215 | ### Templates |
| 135 | 216 | |
| 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 %}`) | |
| 139 | 220 | - CSRF token sent with all HTMX requests via `htmx:configRequest` event |
| 140 | 221 | |
| 141 | 222 | Alpine.js patterns for client-side interactivity: |
| 142 | 223 | ```html |
| 143 | 224 | <div x-data="{ open: false }"> |
| @@ -239,5 +320,23 @@ | ||
| 239 | 320 | make lint # Run Ruff check + format |
| 240 | 321 | make superuser # Create Django superuser |
| 241 | 322 | make shell # Shell into container |
| 242 | 323 | make logs # Tail Django logs |
| 243 | 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. | |
| 244 | 343 |
| --- 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 @@ | ||
| 46 | 46 | "django.contrib.auth", |
| 47 | 47 | "django.contrib.contenttypes", |
| 48 | 48 | "django.contrib.sessions", |
| 49 | 49 | "django.contrib.messages", |
| 50 | 50 | "django.contrib.staticfiles", |
| 51 | + "django.contrib.humanize", | |
| 51 | 52 | # Third-party |
| 52 | 53 | "import_export", |
| 53 | 54 | "simple_history", |
| 54 | 55 | "django_celery_results", |
| 55 | 56 | "django_celery_beat", |
| @@ -59,10 +60,13 @@ | ||
| 59 | 60 | # Project apps |
| 60 | 61 | "core", |
| 61 | 62 | "auth1", |
| 62 | 63 | "organization", |
| 63 | 64 | "items", |
| 65 | + "projects", | |
| 66 | + "pages", | |
| 67 | + "fossil", | |
| 64 | 68 | "testdata", |
| 65 | 69 | ] |
| 66 | 70 | |
| 67 | 71 | MIDDLEWARE = [ |
| 68 | 72 | "corsheaders.middleware.CorsMiddleware", |
| @@ -87,10 +91,11 @@ | ||
| 87 | 91 | "context_processors": [ |
| 88 | 92 | "django.template.context_processors.debug", |
| 89 | 93 | "django.template.context_processors.request", |
| 90 | 94 | "django.contrib.auth.context_processors.auth", |
| 91 | 95 | "django.contrib.messages.context_processors.messages", |
| 96 | + "core.context_processors.sidebar", | |
| 92 | 97 | ], |
| 93 | 98 | }, |
| 94 | 99 | }, |
| 95 | 100 | ] |
| 96 | 101 | |
| @@ -200,10 +205,19 @@ | ||
| 200 | 205 | # --- Constance (runtime feature toggles) --- |
| 201 | 206 | |
| 202 | 207 | CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" |
| 203 | 208 | CONSTANCE_CONFIG = { |
| 204 | 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"), | |
| 205 | 219 | } |
| 206 | 220 | |
| 207 | 221 | # --- Sentry --- |
| 208 | 222 | |
| 209 | 223 | SENTRY_DSN = env_str("SENTRY_DSN") |
| 210 | 224 |
| --- 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 @@ | ||
| 188 | 188 | urlpatterns = [ |
| 189 | 189 | path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)), |
| 190 | 190 | path("status/", status_page, name="status"), |
| 191 | 191 | path("dashboard/", include("core.urls")), |
| 192 | 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")), | |
| 193 | 197 | path("items/", include("items.urls")), |
| 194 | 198 | path("admin/", admin.site.urls), |
| 195 | 199 | path("health/", health_check, name="health"), |
| 196 | 200 | ] |
| 197 | 201 |
| --- 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 |
| --- conftest.py | ||
| +++ conftest.py | ||
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import pytest |
| 2 | 2 | from django.contrib.auth.models import Group, Permission, User |
| 3 | 3 | |
| 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 | |
| 5 | 7 | |
| 6 | 8 | |
| 7 | 9 | @pytest.fixture |
| 8 | 10 | def admin_user(db): |
| 9 | 11 | user = User.objects.create_superuser(username="admin", email="[email protected]", password="testpass123") |
| @@ -12,11 +14,14 @@ | ||
| 12 | 14 | |
| 13 | 15 | @pytest.fixture |
| 14 | 16 | def viewer_user(db): |
| 15 | 17 | user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123") |
| 16 | 18 | 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 | + ) | |
| 18 | 23 | group.permissions.set(view_perms) |
| 19 | 24 | user.groups.add(group) |
| 20 | 25 | return user |
| 21 | 26 | |
| 22 | 27 | |
| @@ -29,10 +34,34 @@ | ||
| 29 | 34 | def org(db, admin_user): |
| 30 | 35 | org = Organization.objects.create(name="Test Org", created_by=admin_user) |
| 31 | 36 | OrganizationMember.objects.create(member=admin_user, organization=org) |
| 32 | 37 | return org |
| 33 | 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 | + | |
| 34 | 63 | |
| 35 | 64 | @pytest.fixture |
| 36 | 65 | def admin_client(client, admin_user): |
| 37 | 66 | client.login(username="admin", password="testpass123") |
| 38 | 67 | return client |
| 39 | 68 | |
| 40 | 69 | 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 @@ | ||
| 13 | 13 | ORGANIZATION_VIEW = "organization.view_organization" |
| 14 | 14 | ORGANIZATION_ADD = "organization.add_organization" |
| 15 | 15 | ORGANIZATION_CHANGE = "organization.change_organization" |
| 16 | 16 | ORGANIZATION_DELETE = "organization.delete_organization" |
| 17 | 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 | + | |
| 18 | 48 | # Items (example domain) |
| 19 | 49 | ITEM_VIEW = "items.view_item" |
| 20 | 50 | ITEM_ADD = "items.add_item" |
| 21 | 51 | ITEM_CHANGE = "items.change_item" |
| 22 | 52 | ITEM_DELETE = "items.delete_item" |
| 23 | 53 | |
| 24 | 54 | ADDED ctl/__init__.py |
| 25 | 55 | ADDED ctl/main.py |
| 26 | 56 | ADDED docker/Caddyfile |
| 27 | 57 | ADDED docker/Dockerfile.fossil |
| 28 | 58 | ADDED docker/docker-compose.fossil.yml |
| 29 | 59 | ADDED docker/litestream.yml |
| 30 | 60 | ADDED fossil-platform/Dockerfile |
| 31 | 61 | ADDED fossil-platform/README.md |
| 32 | 62 | ADDED fossil/__init__.py |
| 33 | 63 | ADDED fossil/admin.py |
| 34 | 64 | ADDED fossil/apps.py |
| 35 | 65 | ADDED fossil/cli.py |
| 36 | 66 | ADDED fossil/migrations/0001_initial.py |
| 37 | 67 | ADDED fossil/migrations/__init__.py |
| 38 | 68 | ADDED fossil/models.py |
| 39 | 69 | ADDED fossil/reader.py |
| 40 | 70 | ADDED fossil/signals.py |
| 41 | 71 | ADDED fossil/tasks.py |
| 42 | 72 | ADDED fossil/urls.py |
| 43 | 73 | 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 |
| --- items/forms.py | ||
| +++ items/forms.py | ||
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | from django import forms |
| 2 | 2 | |
| 3 | 3 | from .models import Item |
| 4 | 4 | |
| 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" | |
| 6 | 6 | |
| 7 | 7 | |
| 8 | 8 | class ItemForm(forms.ModelForm): |
| 9 | 9 | class Meta: |
| 10 | 10 | model = Item |
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | widgets = { |
| 13 | 13 | "name": forms.TextInput(attrs={"class": tw, "placeholder": "Item name"}), |
| 14 | 14 | "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}), |
| 15 | 15 | "price": forms.NumberInput(attrs={"class": tw, "step": "0.01", "placeholder": "0.00"}), |
| 16 | 16 | "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"}), | |
| 18 | 18 | } |
| 19 | 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-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 @@ | ||
| 1 | 1 | from django.contrib import admin |
| 2 | 2 | |
| 3 | 3 | from core.admin import BaseCoreAdmin |
| 4 | 4 | |
| 5 | -from .models import Organization, OrganizationMember | |
| 5 | +from .models import Organization, OrganizationMember, Team | |
| 6 | 6 | |
| 7 | 7 | |
| 8 | 8 | class OrganizationMemberInline(admin.TabularInline): |
| 9 | 9 | model = OrganizationMember |
| 10 | 10 | extra = 0 |
| @@ -15,11 +15,18 @@ | ||
| 15 | 15 | class OrganizationAdmin(BaseCoreAdmin): |
| 16 | 16 | list_display = ("name", "slug", "website", "created_at") |
| 17 | 17 | search_fields = ("name", "slug") |
| 18 | 18 | inlines = [OrganizationMemberInline] |
| 19 | 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 | + | |
| 20 | 27 | |
| 21 | 28 | @admin.register(OrganizationMember) |
| 22 | 29 | class OrganizationMemberAdmin(BaseCoreAdmin): |
| 23 | 30 | list_display = ("member", "organization", "is_active", "created_at") |
| 24 | 31 | list_filter = ("is_active",) |
| 25 | 32 | raw_id_fields = ("member", "organization") |
| 26 | 33 | |
| 27 | 34 | ADDED organization/forms.py |
| 28 | 35 | 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | class Organization(BaseCoreModel): |
| 8 | 8 | website = models.URLField(blank=True, default="") |
| 9 | 9 | groups = models.ManyToManyField(Group, blank=True, related_name="organizations") |
| 10 | 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 | + | |
| 11 | 22 | objects = ActiveManager() |
| 12 | 23 | all_objects = models.Manager() |
| 13 | 24 | |
| 14 | 25 | class Meta: |
| 15 | 26 | ordering = ["name"] |
| 16 | 27 |
| --- 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 @@ | ||
| 1 | 1 | import pytest |
| 2 | 2 | from django.contrib.auth.models import User |
| 3 | 3 | |
| 4 | -from .models import Organization, OrganizationMember | |
| 4 | +from .models import Organization, OrganizationMember, Team | |
| 5 | 5 | |
| 6 | 6 | |
| 7 | 7 | @pytest.mark.django_db |
| 8 | 8 | class TestOrganization: |
| 9 | 9 | def test_create_organization(self): |
| @@ -31,5 +31,149 @@ | ||
| 31 | 31 | OrganizationMember.objects.create(member=admin_user, organization=org) |
| 32 | 32 | |
| 33 | 33 | def test_str_representation(self, admin_user, org): |
| 34 | 34 | member = OrganizationMember.objects.get(member=admin_user, organization=org) |
| 35 | 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() | |
| 36 | 180 | |
| 37 | 181 | ADDED organization/urls.py |
| 38 | 182 | ADDED organization/views.py |
| 39 | 183 | ADDED pages/__init__.py |
| 40 | 184 | ADDED pages/admin.py |
| 41 | 185 | ADDED pages/apps.py |
| 42 | 186 | ADDED pages/forms.py |
| 43 | 187 | ADDED pages/migrations/0001_initial.py |
| 44 | 188 | ADDED pages/migrations/__init__.py |
| 45 | 189 | ADDED pages/models.py |
| 46 | 190 | ADDED pages/tests.py |
| 47 | 191 | ADDED pages/urls.py |
| 48 | 192 | ADDED pages/views.py |
| 49 | 193 | ADDED projects/__init__.py |
| 50 | 194 | ADDED projects/admin.py |
| 51 | 195 | ADDED projects/apps.py |
| 52 | 196 | ADDED projects/forms.py |
| 53 | 197 | ADDED projects/migrations/0001_initial.py |
| 54 | 198 | ADDED projects/migrations/__init__.py |
| 55 | 199 | ADDED projects/models.py |
| 56 | 200 | ADDED projects/tests.py |
| 57 | 201 | ADDED projects/urls.py |
| 58 | 202 | 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 |