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,18 @@ | ||
| 1 | +from pages.models import Page | |
| 2 | +from projects.models import Project | |
| 3 | + | |
| 4 | + | |
| 5 | +def sidebar(request): | |
| 6 | + if not request.user.is_authenticated: | |
| 7 | + return {} | |
| 8 | + | |
| 9 | + proje | |
| 10 | +est.user.is_superuser: | |
| 11 | + ) | |
| 12 | + pages = Page.objects.filter(is_published=True) | |
| 13 | + if request.user.has_perm("pages.change_page") or request.user.is_superuser: | |
| 14 | + pagesreturn {ouped | |
| 15 | + } | |
| 16 | +return { | |
| 17 | + pages": pages, | |
| 18 | + } |
| --- a/core/context_processors.py | |
| +++ b/core/context_processors.py | |
| @@ -0,0 +1,18 @@ | |
| --- a/core/context_processors.py | |
| +++ b/core/context_processors.py | |
| @@ -0,0 +1,18 @@ | |
| 1 | from pages.models import Page |
| 2 | from projects.models import Project |
| 3 | |
| 4 | |
| 5 | def sidebar(request): |
| 6 | if not request.user.is_authenticated: |
| 7 | return {} |
| 8 | |
| 9 | proje |
| 10 | est.user.is_superuser: |
| 11 | ) |
| 12 | pages = Page.objects.filter(is_published=True) |
| 13 | if request.user.has_perm("pages.change_page") or request.user.is_superuser: |
| 14 | pagesreturn {ouped |
| 15 | } |
| 16 | return { |
| 17 | pages": pages, |
| 18 | } |
| --- core/permissions.py | ||
| +++ core/permissions.py | ||
| @@ -13,10 +13,40 @@ | ||
| 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,3 @@ | ||
| 1 | +syncpstream remote URL for syncme | |
| 2 | + | |
| 3 | + @pro |
| --- a/fossil/models.py | |
| +++ b/fossil/models.py | |
| @@ -0,0 +1,3 @@ | |
| --- a/fossil/models.py | |
| +++ b/fossil/models.py | |
| @@ -0,0 +1,3 @@ | |
| 1 | syncpstream remote URL for syncme |
| 2 | |
| 3 | @pro |
| --- a/fossil/reader.py | ||
| +++ b/fossil/reader.py | ||
| @@ -0,0 +1,513 @@ | ||
| 1 | +"""Read-only interface to Fossil's SQLite database. | |
| 2 | + | |
| 3 | +Each .fossil file is a SQLite database containing all repo data: | |
| 4 | +code, timeline, tickets, wiki, forum. This module reads them directly | |
| 5 | +without requiring the fossil binary. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +import contextlib | |
| 9 | +import sqlite3 | |
| 10 | +import zlib | |
| 11 | +from dataclasses import dataclass, field | |
| 12 | +from datetime import UTC, datetime | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | + | |
| 16 | +@dataclass | |
| 17 | +class TimelineEntry: | |
| 18 | + rid: int | |
| 19 | + uuid: str | |
| 20 | + event_type: str # ci=checkin, w=wiki, t=ticket, g=tag, e=technote, f=forum | |
| 21 | + timestamp: datetime | |
| 22 | + user: str | |
| 23 | + comment: str | |
| 24 | + branch: str = "" | |
| 25 | + parent_rid: int = 0 # primary parent rid for DAG drawing | |
| 26 | + is_merge: bool = False # has multiple parents | |
| 27 | + rail: int = 0 # column position for DAG graph | |
| 28 | + | |
| 29 | + | |
| 30 | +@dataclass | |
| 31 | +class FileEntry: | |
| 32 | + name: str | |
| 33 | + uuid: str | |
| 34 | + size: int | |
| 35 | + is_dir: bool = False | |
| 36 | + last_commit_message: str = "" | |
| 37 | + last_commit_user: str = "" | |
| 38 | + last_commit_time: datetime | None = None | |
| 39 | + | |
| 40 | + | |
| 41 | +@dataclass | |
| 42 | +class TicketEntry: | |
| 43 | + uuid: str | |
| 44 | + title: str | |
| 45 | + status: str | |
| 46 | + type: str | |
| 47 | + created: datetime | |
| 48 | + owner: str | |
| 49 | + subsystem: str = "" | |
| 50 | + priority: str = "" | |
| 51 | + | |
| 52 | + | |
| 53 | +@dataclass | |
| 54 | +class WikiPage: | |
| 55 | + name: str | |
| 56 | + content: str | |
| 57 | + last_modified: datetime | |
| 58 | + user: str | |
| 59 | + | |
| 60 | + | |
| 61 | +@dataclass | |
| 62 | +class ForumPost: | |
| 63 | + uuid: str | |
| 64 | + title: str | |
| 65 | + body: str | |
| 66 | + timestamp: datetime | |
| 67 | + user: str | |
| 68 | + in_reply_to: str = "" | |
| 69 | + | |
| 70 | + | |
| 71 | +@dataclass | |
| 72 | +class RepoMetadata: | |
| 73 | + project_name: str = "" | |
| 74 | + project_code: str = "" | |
| 75 | + checkin_count: int = 0 | |
| 76 | + file_count: int = 0 | |
| 77 | + wiki_page_count: int = 0 | |
| 78 | + ticket_count: int = 0 | |
| 79 | + branches: list[str] = field(default_factory=list) | |
| 80 | + | |
| 81 | + | |
| 82 | +def _julian_to_datetime(julian: float) -> datetime: | |
| 83 | + """Convert Julian day number to Python datetime (UTC).""" | |
| 84 | + | |
| 85 | + # Julian day epoch is Jan 1, 4713 BC (proleptic Julian calendar) | |
| 86 | + # Unix epoch in Julian days = 2440587.5 | |
| 87 | + unix_ts = (julian - 2440587.5) * 86400.0 | |
| 88 | + return datetime.fromtimestamp(unix_ts, tz=UTC) | |
| 89 | + | |
| 90 | + | |
| 91 | +def _decompress_blob(data: bytes) -> bytes: | |
| 92 | + """Decompress a Fossil blob. | |
| 93 | + | |
| 94 | + Fossil stores blobs with a 4-byte big-endian size prefix followed by | |
| 95 | + zlib-compressed content. The size prefix is the uncompressed size. | |
| 96 | + """ | |
| 97 | + if not data: | |
| 98 | + return b"" | |
| 99 | + # Fossil prepends uncompressed size as 4-byte big-endian int | |
| 100 | + if len(data) > 4: | |
| 101 | + payload = data[4:] | |
| 102 | + try: | |
| 103 | + return zlib.decompress(payload) | |
| 104 | + except zlib.error: | |
| 105 | + pass | |
| 106 | + # Fallback: try without size prefix | |
| 107 | + try: | |
| 108 | + return zlib.decompress(data) | |
| 109 | + except zlib.error: | |
| 110 | + pass | |
| 111 | + try: | |
| 112 | + return zlib.decompress(data, -zlib.MAX_WBITS) | |
| 113 | + except zlib.error: | |
| 114 | + return data # Already uncompressed or unknown format | |
| 115 | + | |
| 116 | + | |
| 117 | +def _extract_wiki_content(artifact_text: str) -> str: | |
| 118 | + """Extract wiki body from a Fossil wiki artifact. | |
| 119 | + | |
| 120 | + Format: header cards (D/L/P/U lines), then W <size>\\n<content>\\nZ <hash> | |
| 121 | + """ | |
| 122 | + import re | |
| 123 | + | |
| 124 | + match = re.search(r"^W \d+\n(.*?)(?:\nZ [0-9a-f]+)?$", artifact_text, re.DOTALL | re.MULTILINE) | |
| 125 | + if match: | |
| 126 | + return match.group(1).strip() | |
| 127 | + return "" | |
| 128 | + | |
| 129 | + | |
| 130 | +class FossilReader: | |
| 131 | + """Read-only interface to a .fossil SQLite database.""" | |
| 132 | + | |
| 133 | + def __init__(self, path: Path): | |
| 134 | + self.path = path | |
| 135 | + self._conn: sqlite3.Connection | None = None | |
| 136 | + | |
| 137 | + def __enter__(self): | |
| 138 | + self._conn = self._connect() | |
| 139 | + return self | |
| 140 | + | |
| 141 | + def __exit__(self, *args): | |
| 142 | + if self._conn: | |
| 143 | + self._conn.close() | |
| 144 | + self._conn = None | |
| 145 | + | |
| 146 | + def _connect(self) -> sqlite3.Connection: | |
| 147 | + uri = f"file:{self.path}?mode=ro" | |
| 148 | + conn = sqlite3.connect(uri, uri=True) | |
| 149 | + conn.row_factory = sqlite3.Row | |
| 150 | + return conn | |
| 151 | + | |
| 152 | + @property | |
| 153 | + def conn(self) -> sqlite3.Connection: | |
| 154 | + if self._conn is None: | |
| 155 | + self._conn = self._connect() | |
| 156 | + return self._conn | |
| 157 | + | |
| 158 | + def close(self): | |
| 159 | + if self._conn: | |
| 160 | + self._conn.close() | |
| 161 | + self._conn = None | |
| 162 | + | |
| 163 | + # --- Metadata --- | |
| 164 | + | |
| 165 | + def get_metadata(self) -> RepoMetadata: | |
| 166 | + meta = RepoMetadata() | |
| 167 | + meta.project_name = self.get_project_name() | |
| 168 | + meta.project_code = self.get_project_code() | |
| 169 | + meta.checkin_count = self.get_checkin_count() | |
| 170 | + with contextlib.suppress(sqlite3.OperationalError): | |
| 171 | + meta.ticket_count = self.conn.execute("SELECT count(*) FROM ticket").fetchone()[0] | |
| 172 | + with contextlib.suppress(sqlite3.OperationalError): | |
| 173 | + meta.wiki_page_count = self.conn.execute( | |
| 174 | + "SELECT count(DISTINCT substr(tagname,6)) FROM tag WHERE tagname LIKE 'wiki-%'" | |
| 175 | + ).fetchone()[0] | |
| 176 | + return meta | |
| 177 | + | |
| 178 | + def get_project_name(self) -> str: | |
| 179 | + try: | |
| 180 | + row = self.conn.execute("SELECT value FROM config WHERE name='project-name'").fetchone() | |
| 181 | + return row[0] if row else "" | |
| 182 | + except sqlite3.OperationalError: | |
| 183 | + return "" | |
| 184 | + | |
| 185 | + def get_project_code(self) -> str: | |
| 186 | + try: | |
| 187 | + row = self.conn.execute("SELECT value FROM config WHERE name='project-code'").fetchone() | |
| 188 | + return row[0] if row else "" | |
| 189 | + except sqlite3.OperationalError: | |
| 190 | + return "" | |
| 191 | + | |
| 192 | + def get_checkin_count(self) -> int: | |
| 193 | + try: | |
| 194 | + row = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone() | |
| 195 | + return row[0] if row else 0 | |
| 196 | + except sqlite3.OperationalError: | |
| 197 | + return 0 | |
| 198 | + | |
| 199 | + # --- Timeline --- | |
| 200 | + | |
| 201 | + def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]: | |
| 202 | + sql = """ | |
| 203 | + SELECT blob.rid, blob.uuid, event.type, event.mtime, event.user, event.comment | |
| 204 | + FROM event | |
| 205 | + JOIN blob ON event.objid = blob.rid | |
| 206 | + """ | |
| 207 | + params: list = [] | |
| 208 | + if event_type: | |
| 209 | + sql += " WHERE event.type = ?" | |
| 210 | + params.append(event_type) | |
| 211 | + sql += " ORDER BY event.mtime DESC LIMIT ? OFFSET ?" | |
| 212 | + params.extend([limit, offset]) | |
| 213 | + | |
| 214 | + entries = [] | |
| 215 | + try: | |
| 216 | + for row in self.conn.execute(sql, params): | |
| 217 | + branch = "" | |
| 218 | + parent_rid = 0 | |
| 219 | + is_merge = False | |
| 220 | + | |
| 221 | + try: | |
| 222 | + br = self.conn.execute( | |
| 223 | + "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid " | |
| 224 | + "WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'", | |
| 225 | + (row["rid"],), | |
| 226 | + ).fetchone() | |
| 227 | + if br: | |
| 228 | + branch = br[0].replace("sym-", "", 1) | |
| 229 | + except sqlite3.OperationalError: | |
| 230 | + pass | |
| 231 | + | |
| 232 | + # Get parent info from plink for DAG | |
| 233 | + if row["type"] == "ci": | |
| 234 | + try: | |
| 235 | + parents = self.conn.execute( | |
| 236 | + "SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],) | |
| 237 | + ).fetchall() | |
| 238 | + for p in parents: | |
| 239 | + if p["isprim"]: | |
| 240 | + parent_rid = p["pid"] | |
| 241 | + is_merge = len(parents) > 1 | |
| 242 | + except sqlite3.OperationalError: | |
| 243 | + pass | |
| 244 | + | |
| 245 | + entries.append( | |
| 246 | + TimelineEntry( | |
| 247 | + rid=row["rid"], | |
| 248 | + uuid=row["uuid"], | |
| 249 | + event_type=row["type"], | |
| 250 | + timestamp=_julian_to_datetime(row["mtime"]), | |
| 251 | + user=row["user"] or "", | |
| 252 | + comment=row["comment"] or "", | |
| 253 | + branch=branch, | |
| 254 | + parent_rid=parent_rid, | |
| 255 | + is_merge=is_merge, | |
| 256 | + ) | |
| 257 | + ) | |
| 258 | + except sqlite3.OperationalError: | |
| 259 | + pass | |
| 260 | + | |
| 261 | + # Assign rail positions based on branches | |
| 262 | + branch_rails: dict[str, int] = {} | |
| 263 | + next_rail = 0 | |
| 264 | + for entry in entries: | |
| 265 | + if entry.event_type != "ci": | |
| 266 | + entry.rail = -1 # non-checkin events don't get a rail | |
| 267 | + continue | |
| 268 | + b = entry.branch or "trunk" | |
| 269 | + if b not in branch_rails: | |
| 270 | + branch_rails[b] = next_rail | |
| 271 | + next_rail += 1 | |
| 272 | + entry.rail = branch_rails[b] | |
| 273 | + | |
| 274 | + return entries | |
| 275 | + | |
| 276 | + # --- Code / Files --- | |
| 277 | + | |
| 278 | + def get_latest_checkin_uuid(self) -> str | None: | |
| 279 | + try: | |
| 280 | + row = self.conn.execute( | |
| 281 | + "SELECT blob.uuid FROM event JOIN blob ON event.objid=blob.rid WHERE event.type='ci' ORDER BY event.mtime DESC LIMIT 1" | |
| 282 | + ).fetchone() | |
| 283 | + return row[0] if row else None | |
| 284 | + except sqlite3.OperationalError: | |
| 285 | + return None | |
| 286 | + | |
| 287 | + def get_files_at_checkin(self, checkin_uuid: str | None = None) -> list[FileEntry]: | |
| 288 | + """Get the cumulative file list at a given checkin, with last commit info per file.""" | |
| 289 | + if checkin_uuid is None: | |
| 290 | + checkin_uuid = self.get_latest_checkin_uuid() | |
| 291 | + if not checkin_uuid: | |
| 292 | + return [] | |
| 293 | + | |
| 294 | + try: | |
| 295 | + # Build cumulative file state: for each filename, find the latest mlink entry | |
| 296 | + # where fid > 0 (fid=0 means file was deleted) | |
| 297 | + rows = self.conn.execute( | |
| 298 | + """ | |
| 299 | + SELECT fn.name, b.uuid, b.size, | |
| 300 | + e.comment, e.user, e.mtime | |
| 301 | + FROM ( | |
| 302 | + SELECT ml.fnid, ml.fid, | |
| 303 | + MAX(e2.mtime) as max_mtime | |
| 304 | + FROM mlink ml | |
| 305 | + JOIN event e2 ON ml.mid = e2.objid | |
| 306 | + WHERE e2.type = 'ci' | |
| 307 | + GROUP BY ml.fnid | |
| 308 | + ) latest | |
| 309 | + JOIN mlink ml2 ON ml2.fnid = latest.fnid | |
| 310 | + JOIN event e ON ml2.mid = e.objid AND e.mtime = latest.max_mtime AND e.type = 'ci' | |
| 311 | + JOIN filename fn ON latest.fnid = fn.fnid | |
| 312 | + LEFT JOIN blob b ON ml2.fid = b.rid | |
| 313 | + WHERE ml2.fid > 0 | |
| 314 | + ORDER BY fn.name | |
| 315 | + """, | |
| 316 | + ).fetchall() | |
| 317 | + | |
| 318 | + return [ | |
| 319 | + FileEntry( | |
| 320 | + name=r["name"], | |
| 321 | + uuid=r["uuid"] or "", | |
| 322 | + size=r["size"] or 0, | |
| 323 | + last_commit_message=r["comment"] or "", | |
| 324 | + last_commit_user=r["user"] or "", | |
| 325 | + last_commit_time=_julian_to_datetime(r["mtime"]) if r["mtime"] else None, | |
| 326 | + ) | |
| 327 | + for r in rows | |
| 328 | + ] | |
| 329 | + except sqlite3.OperationalError: | |
| 330 | + return [] | |
| 331 | + | |
| 332 | + def get_file_content(self, blob_uuid: str) -> bytes: | |
| 333 | + try: | |
| 334 | + row = self.conn.execute("SELECT content FROM blob WHERE uuid=?", (blob_uuid,)).fetchone() | |
| 335 | + if not row or not row[0]: | |
| 336 | + return b"" | |
| 337 | + return _decompress_blob(row[0]) | |
| 338 | + except sqlite3.OperationalError: | |
| 339 | + return b"" | |
| 340 | + | |
| 341 | + # --- Tickets --- | |
| 342 | + | |
| 343 | + def get_tickets(self, status: str | None = None, limit: int = 50) -> list[TicketEntry]: | |
| 344 | + sql = "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority FROM ticket" | |
| 345 | + params: list = [] | |
| 346 | + if status: | |
| 347 | + sql += " WHERE status = ?" | |
| 348 | + params.append(status) | |
| 349 | + sql += " ORDER BY tkt_ctime DESC LIMIT ?" | |
| 350 | + params.append(limit) | |
| 351 | + | |
| 352 | + entries = [] | |
| 353 | + try: | |
| 354 | + for row in self.conn.execute(sql, params): | |
| 355 | + entries.append( | |
| 356 | + TicketEntry( | |
| 357 | + uuid=row["tkt_uuid"] or "", | |
| 358 | + title=row["title"] or "", | |
| 359 | + status=row["status"] or "", | |
| 360 | + type=row["type"] or "", | |
| 361 | + created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC), | |
| 362 | + owner="", | |
| 363 | + subsystem=row["subsystem"] or "", | |
| 364 | + priority=row["priority"] or "", | |
| 365 | + ) | |
| 366 | + ) | |
| 367 | + except sqlite3.OperationalError: | |
| 368 | + pass | |
| 369 | + return entries | |
| 370 | + | |
| 371 | + def get_ticket_detail(self, uuid: str) -> TicketEntry | None: | |
| 372 | + try: | |
| 373 | + row = self.conn.execute( | |
| 374 | + "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority " | |
| 375 | + "FROM ticket WHERE tkt_uuid LIKE ?", | |
| 376 | + (uuid + "%",), | |
| 377 | + ).fetchone() | |
| 378 | + if not row: | |
| 379 | + return None | |
| 380 | + return TicketEntry( | |
| 381 | + uuid=row["tkt_uuid"], | |
| 382 | + title=row["title"] or "", | |
| 383 | + status=row["status"] or "", | |
| 384 | + type=row["type"] or "", | |
| 385 | + created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC), | |
| 386 | + owner="", | |
| 387 | + subsystem=row["subsystem"] or "", | |
| 388 | + priority=row["priority"] or "", | |
| 389 | + ) | |
| 390 | + except sqlite3.OperationalError: | |
| 391 | + return None | |
| 392 | + | |
| 393 | + # --- Wiki --- | |
| 394 | + | |
| 395 | + def get_wiki_pages(self) -> list[WikiPage]: | |
| 396 | + pages = [] | |
| 397 | + try: | |
| 398 | + rows = self.conn.execute( | |
| 399 | + """ | |
| 400 | + SELECT substr(tag.tagname, 6) as name, event.mtime, event.user | |
| 401 | + FROM tag | |
| 402 | + JOIN tagxref ON tag.tagid = tagxref.tagid | |
| 403 | + JOIN event ON tagxref.rid = event.objid | |
| 404 | + WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w' | |
| 405 | + GROUP BY tag.tagname | |
| 406 | + HAVING event.mtime = MAX(event.mtime) | |
| 407 | + ORDER BY name | |
| 408 | + """ | |
| 409 | + ).fetchall() | |
| 410 | + for row in rows: | |
| 411 | + pages.append( | |
| 412 | + WikiPage( | |
| 413 | + name=row["name"], | |
| 414 | + content="", | |
| 415 | + last_modified=_julian_to_datetime(row["mtime"]), | |
| 416 | + user=row["user"] or "", | |
| 417 | + ) | |
| 418 | + ) | |
| 419 | + except sqlite3.OperationalError: | |
| 420 | + pass | |
| 421 | + return pages | |
| 422 | + | |
| 423 | + def get_wiki_page(self, name: str) -> WikiPage | None: | |
| 424 | + try: | |
| 425 | + row = self.conn.execute( | |
| 426 | + """ | |
| 427 | + SELECT tagxref.rid, event.mtime, event.user | |
| 428 | + FROM tag | |
| 429 | + JOIN tagxref ON tag.tagid = tagxref.tagid | |
| 430 | + JOIN event ON tagxref.rid = event.objid | |
| 431 | + WHERE tag.tagname = ? AND event.type = 'w' | |
| 432 | + ORDER BY event.mtime DESC | |
| 433 | + LIMIT 1 | |
| 434 | + """, | |
| 435 | + (f"wiki-{name}",), | |
| 436 | + ).fetchone() | |
| 437 | + if not row: | |
| 438 | + return None | |
| 439 | + | |
| 440 | + # Read the wiki content from the blob | |
| 441 | + blob_row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (row["rid"],)).fetchone() | |
| 442 | + content = "" | |
| 443 | + if blob_row and blob_row[0]: | |
| 444 | + raw = _decompress_blob(blob_row[0]) | |
| 445 | + text = raw.decode("utf-8", errors="replace") | |
| 446 | + # Fossil wiki artifact format: header cards (D/L/P/U) then W <size>\n<content>\nZ <hash> | |
| 447 | + content = _extract_wiki_content(text) | |
| 448 | + | |
| 449 | + return WikiPage( | |
| 450 | + name=name, | |
| 451 | + content=content, | |
| 452 | + last_modified=_julian_to_datetime(row["mtime"]), | |
| 453 | + user=row["user"] or "", | |
| 454 | + ) | |
| 455 | + except sqlite3.OperationalError: | |
| 456 | + return None | |
| 457 | + | |
| 458 | + # --- Forum --- | |
| 459 | + | |
| 460 | + def get_forum_posts(self, limit: int = 50) -> list[ForumPost]: | |
| 461 | + posts = [] | |
| 462 | + try: | |
| 463 | + rows = self.conn.execute( | |
| 464 | + """ | |
| 465 | + SELECT blob.uuid, event.mtime, event.user, event.comment | |
| 466 | + FROM event | |
| 467 | + JOIN blob ON event.objid = blob.rid | |
| 468 | + WHERE event.type = 'f' | |
| 469 | + ORDER BY event.mtime DESC | |
| 470 | + LIMIT ? | |
| 471 | + """, | |
| 472 | + (limit,), | |
| 473 | + ).fetchall() | |
| 474 | + for row in rows: | |
| 475 | + posts.append( | |
| 476 | + ForumPost( | |
| 477 | + uuid=row["uuid"], | |
| 478 | + title=row["comment"] or "", | |
| 479 | + body="", | |
| 480 | + timestamp=_julian_to_datetime(row["mtime"]), | |
| 481 | + user=row["user"] or "", | |
| 482 | + ) | |
| 483 | + ) | |
| 484 | + except sqlite3.OperationalError: | |
| 485 | + pass | |
| 486 | + return posts | |
| 487 | + | |
| 488 | + def get_forum_thread(self, root_uuid: str) -> list[ForumPost]: | |
| 489 | + # Forum threads in Fossil are linked via the forumpost table | |
| 490 | + posts = [] | |
| 491 | + try: | |
| 492 | + rows = self.conn.execute( | |
| 493 | + """ | |
| 494 | + SELECT blob.uuid, event.mtime, event.user, event.comment | |
| 495 | + FROM event | |
| 496 | + JOIN blob ON event.objid = blob.rid | |
| 497 | + WHERE event.type = 'f' | |
| 498 | + ORDER BY event.mtime ASC | |
| 499 | + """ | |
| 500 | + ).fetchall() | |
| 501 | + for row in rows: | |
| 502 | + posts.append( | |
| 503 | + ForumPost( | |
| 504 | + uuid=row["uuid"], | |
| 505 | + title=row["comment"] or "", | |
| 506 | + body="", | |
| 507 | + timestamp=_julian_to_datetime(row["mtime"]), | |
| 508 | + user=row["user"] or "", | |
| 509 | + ) | |
| 510 | + ) | |
| 511 | + except sqlite3.OperationalError: | |
| 512 | + pass | |
| 513 | + return posts |
| --- a/fossil/reader.py | |
| +++ b/fossil/reader.py | |
| @@ -0,0 +1,513 @@ | |
| --- a/fossil/reader.py | |
| +++ b/fossil/reader.py | |
| @@ -0,0 +1,513 @@ | |
| 1 | """Read-only interface to Fossil's SQLite database. |
| 2 | |
| 3 | Each .fossil file is a SQLite database containing all repo data: |
| 4 | code, timeline, tickets, wiki, forum. This module reads them directly |
| 5 | without requiring the fossil binary. |
| 6 | """ |
| 7 | |
| 8 | import contextlib |
| 9 | import sqlite3 |
| 10 | import zlib |
| 11 | from dataclasses import dataclass, field |
| 12 | from datetime import UTC, datetime |
| 13 | from pathlib import Path |
| 14 | |
| 15 | |
| 16 | @dataclass |
| 17 | class TimelineEntry: |
| 18 | rid: int |
| 19 | uuid: str |
| 20 | event_type: str # ci=checkin, w=wiki, t=ticket, g=tag, e=technote, f=forum |
| 21 | timestamp: datetime |
| 22 | user: str |
| 23 | comment: str |
| 24 | branch: str = "" |
| 25 | parent_rid: int = 0 # primary parent rid for DAG drawing |
| 26 | is_merge: bool = False # has multiple parents |
| 27 | rail: int = 0 # column position for DAG graph |
| 28 | |
| 29 | |
| 30 | @dataclass |
| 31 | class FileEntry: |
| 32 | name: str |
| 33 | uuid: str |
| 34 | size: int |
| 35 | is_dir: bool = False |
| 36 | last_commit_message: str = "" |
| 37 | last_commit_user: str = "" |
| 38 | last_commit_time: datetime | None = None |
| 39 | |
| 40 | |
| 41 | @dataclass |
| 42 | class TicketEntry: |
| 43 | uuid: str |
| 44 | title: str |
| 45 | status: str |
| 46 | type: str |
| 47 | created: datetime |
| 48 | owner: str |
| 49 | subsystem: str = "" |
| 50 | priority: str = "" |
| 51 | |
| 52 | |
| 53 | @dataclass |
| 54 | class WikiPage: |
| 55 | name: str |
| 56 | content: str |
| 57 | last_modified: datetime |
| 58 | user: str |
| 59 | |
| 60 | |
| 61 | @dataclass |
| 62 | class ForumPost: |
| 63 | uuid: str |
| 64 | title: str |
| 65 | body: str |
| 66 | timestamp: datetime |
| 67 | user: str |
| 68 | in_reply_to: str = "" |
| 69 | |
| 70 | |
| 71 | @dataclass |
| 72 | class RepoMetadata: |
| 73 | project_name: str = "" |
| 74 | project_code: str = "" |
| 75 | checkin_count: int = 0 |
| 76 | file_count: int = 0 |
| 77 | wiki_page_count: int = 0 |
| 78 | ticket_count: int = 0 |
| 79 | branches: list[str] = field(default_factory=list) |
| 80 | |
| 81 | |
| 82 | def _julian_to_datetime(julian: float) -> datetime: |
| 83 | """Convert Julian day number to Python datetime (UTC).""" |
| 84 | |
| 85 | # Julian day epoch is Jan 1, 4713 BC (proleptic Julian calendar) |
| 86 | # Unix epoch in Julian days = 2440587.5 |
| 87 | unix_ts = (julian - 2440587.5) * 86400.0 |
| 88 | return datetime.fromtimestamp(unix_ts, tz=UTC) |
| 89 | |
| 90 | |
| 91 | def _decompress_blob(data: bytes) -> bytes: |
| 92 | """Decompress a Fossil blob. |
| 93 | |
| 94 | Fossil stores blobs with a 4-byte big-endian size prefix followed by |
| 95 | zlib-compressed content. The size prefix is the uncompressed size. |
| 96 | """ |
| 97 | if not data: |
| 98 | return b"" |
| 99 | # Fossil prepends uncompressed size as 4-byte big-endian int |
| 100 | if len(data) > 4: |
| 101 | payload = data[4:] |
| 102 | try: |
| 103 | return zlib.decompress(payload) |
| 104 | except zlib.error: |
| 105 | pass |
| 106 | # Fallback: try without size prefix |
| 107 | try: |
| 108 | return zlib.decompress(data) |
| 109 | except zlib.error: |
| 110 | pass |
| 111 | try: |
| 112 | return zlib.decompress(data, -zlib.MAX_WBITS) |
| 113 | except zlib.error: |
| 114 | return data # Already uncompressed or unknown format |
| 115 | |
| 116 | |
| 117 | def _extract_wiki_content(artifact_text: str) -> str: |
| 118 | """Extract wiki body from a Fossil wiki artifact. |
| 119 | |
| 120 | Format: header cards (D/L/P/U lines), then W <size>\\n<content>\\nZ <hash> |
| 121 | """ |
| 122 | import re |
| 123 | |
| 124 | match = re.search(r"^W \d+\n(.*?)(?:\nZ [0-9a-f]+)?$", artifact_text, re.DOTALL | re.MULTILINE) |
| 125 | if match: |
| 126 | return match.group(1).strip() |
| 127 | return "" |
| 128 | |
| 129 | |
| 130 | class FossilReader: |
| 131 | """Read-only interface to a .fossil SQLite database.""" |
| 132 | |
| 133 | def __init__(self, path: Path): |
| 134 | self.path = path |
| 135 | self._conn: sqlite3.Connection | None = None |
| 136 | |
| 137 | def __enter__(self): |
| 138 | self._conn = self._connect() |
| 139 | return self |
| 140 | |
| 141 | def __exit__(self, *args): |
| 142 | if self._conn: |
| 143 | self._conn.close() |
| 144 | self._conn = None |
| 145 | |
| 146 | def _connect(self) -> sqlite3.Connection: |
| 147 | uri = f"file:{self.path}?mode=ro" |
| 148 | conn = sqlite3.connect(uri, uri=True) |
| 149 | conn.row_factory = sqlite3.Row |
| 150 | return conn |
| 151 | |
| 152 | @property |
| 153 | def conn(self) -> sqlite3.Connection: |
| 154 | if self._conn is None: |
| 155 | self._conn = self._connect() |
| 156 | return self._conn |
| 157 | |
| 158 | def close(self): |
| 159 | if self._conn: |
| 160 | self._conn.close() |
| 161 | self._conn = None |
| 162 | |
| 163 | # --- Metadata --- |
| 164 | |
| 165 | def get_metadata(self) -> RepoMetadata: |
| 166 | meta = RepoMetadata() |
| 167 | meta.project_name = self.get_project_name() |
| 168 | meta.project_code = self.get_project_code() |
| 169 | meta.checkin_count = self.get_checkin_count() |
| 170 | with contextlib.suppress(sqlite3.OperationalError): |
| 171 | meta.ticket_count = self.conn.execute("SELECT count(*) FROM ticket").fetchone()[0] |
| 172 | with contextlib.suppress(sqlite3.OperationalError): |
| 173 | meta.wiki_page_count = self.conn.execute( |
| 174 | "SELECT count(DISTINCT substr(tagname,6)) FROM tag WHERE tagname LIKE 'wiki-%'" |
| 175 | ).fetchone()[0] |
| 176 | return meta |
| 177 | |
| 178 | def get_project_name(self) -> str: |
| 179 | try: |
| 180 | row = self.conn.execute("SELECT value FROM config WHERE name='project-name'").fetchone() |
| 181 | return row[0] if row else "" |
| 182 | except sqlite3.OperationalError: |
| 183 | return "" |
| 184 | |
| 185 | def get_project_code(self) -> str: |
| 186 | try: |
| 187 | row = self.conn.execute("SELECT value FROM config WHERE name='project-code'").fetchone() |
| 188 | return row[0] if row else "" |
| 189 | except sqlite3.OperationalError: |
| 190 | return "" |
| 191 | |
| 192 | def get_checkin_count(self) -> int: |
| 193 | try: |
| 194 | row = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone() |
| 195 | return row[0] if row else 0 |
| 196 | except sqlite3.OperationalError: |
| 197 | return 0 |
| 198 | |
| 199 | # --- Timeline --- |
| 200 | |
| 201 | def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]: |
| 202 | sql = """ |
| 203 | SELECT blob.rid, blob.uuid, event.type, event.mtime, event.user, event.comment |
| 204 | FROM event |
| 205 | JOIN blob ON event.objid = blob.rid |
| 206 | """ |
| 207 | params: list = [] |
| 208 | if event_type: |
| 209 | sql += " WHERE event.type = ?" |
| 210 | params.append(event_type) |
| 211 | sql += " ORDER BY event.mtime DESC LIMIT ? OFFSET ?" |
| 212 | params.extend([limit, offset]) |
| 213 | |
| 214 | entries = [] |
| 215 | try: |
| 216 | for row in self.conn.execute(sql, params): |
| 217 | branch = "" |
| 218 | parent_rid = 0 |
| 219 | is_merge = False |
| 220 | |
| 221 | try: |
| 222 | br = self.conn.execute( |
| 223 | "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid " |
| 224 | "WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'", |
| 225 | (row["rid"],), |
| 226 | ).fetchone() |
| 227 | if br: |
| 228 | branch = br[0].replace("sym-", "", 1) |
| 229 | except sqlite3.OperationalError: |
| 230 | pass |
| 231 | |
| 232 | # Get parent info from plink for DAG |
| 233 | if row["type"] == "ci": |
| 234 | try: |
| 235 | parents = self.conn.execute( |
| 236 | "SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],) |
| 237 | ).fetchall() |
| 238 | for p in parents: |
| 239 | if p["isprim"]: |
| 240 | parent_rid = p["pid"] |
| 241 | is_merge = len(parents) > 1 |
| 242 | except sqlite3.OperationalError: |
| 243 | pass |
| 244 | |
| 245 | entries.append( |
| 246 | TimelineEntry( |
| 247 | rid=row["rid"], |
| 248 | uuid=row["uuid"], |
| 249 | event_type=row["type"], |
| 250 | timestamp=_julian_to_datetime(row["mtime"]), |
| 251 | user=row["user"] or "", |
| 252 | comment=row["comment"] or "", |
| 253 | branch=branch, |
| 254 | parent_rid=parent_rid, |
| 255 | is_merge=is_merge, |
| 256 | ) |
| 257 | ) |
| 258 | except sqlite3.OperationalError: |
| 259 | pass |
| 260 | |
| 261 | # Assign rail positions based on branches |
| 262 | branch_rails: dict[str, int] = {} |
| 263 | next_rail = 0 |
| 264 | for entry in entries: |
| 265 | if entry.event_type != "ci": |
| 266 | entry.rail = -1 # non-checkin events don't get a rail |
| 267 | continue |
| 268 | b = entry.branch or "trunk" |
| 269 | if b not in branch_rails: |
| 270 | branch_rails[b] = next_rail |
| 271 | next_rail += 1 |
| 272 | entry.rail = branch_rails[b] |
| 273 | |
| 274 | return entries |
| 275 | |
| 276 | # --- Code / Files --- |
| 277 | |
| 278 | def get_latest_checkin_uuid(self) -> str | None: |
| 279 | try: |
| 280 | row = self.conn.execute( |
| 281 | "SELECT blob.uuid FROM event JOIN blob ON event.objid=blob.rid WHERE event.type='ci' ORDER BY event.mtime DESC LIMIT 1" |
| 282 | ).fetchone() |
| 283 | return row[0] if row else None |
| 284 | except sqlite3.OperationalError: |
| 285 | return None |
| 286 | |
| 287 | def get_files_at_checkin(self, checkin_uuid: str | None = None) -> list[FileEntry]: |
| 288 | """Get the cumulative file list at a given checkin, with last commit info per file.""" |
| 289 | if checkin_uuid is None: |
| 290 | checkin_uuid = self.get_latest_checkin_uuid() |
| 291 | if not checkin_uuid: |
| 292 | return [] |
| 293 | |
| 294 | try: |
| 295 | # Build cumulative file state: for each filename, find the latest mlink entry |
| 296 | # where fid > 0 (fid=0 means file was deleted) |
| 297 | rows = self.conn.execute( |
| 298 | """ |
| 299 | SELECT fn.name, b.uuid, b.size, |
| 300 | e.comment, e.user, e.mtime |
| 301 | FROM ( |
| 302 | SELECT ml.fnid, ml.fid, |
| 303 | MAX(e2.mtime) as max_mtime |
| 304 | FROM mlink ml |
| 305 | JOIN event e2 ON ml.mid = e2.objid |
| 306 | WHERE e2.type = 'ci' |
| 307 | GROUP BY ml.fnid |
| 308 | ) latest |
| 309 | JOIN mlink ml2 ON ml2.fnid = latest.fnid |
| 310 | JOIN event e ON ml2.mid = e.objid AND e.mtime = latest.max_mtime AND e.type = 'ci' |
| 311 | JOIN filename fn ON latest.fnid = fn.fnid |
| 312 | LEFT JOIN blob b ON ml2.fid = b.rid |
| 313 | WHERE ml2.fid > 0 |
| 314 | ORDER BY fn.name |
| 315 | """, |
| 316 | ).fetchall() |
| 317 | |
| 318 | return [ |
| 319 | FileEntry( |
| 320 | name=r["name"], |
| 321 | uuid=r["uuid"] or "", |
| 322 | size=r["size"] or 0, |
| 323 | last_commit_message=r["comment"] or "", |
| 324 | last_commit_user=r["user"] or "", |
| 325 | last_commit_time=_julian_to_datetime(r["mtime"]) if r["mtime"] else None, |
| 326 | ) |
| 327 | for r in rows |
| 328 | ] |
| 329 | except sqlite3.OperationalError: |
| 330 | return [] |
| 331 | |
| 332 | def get_file_content(self, blob_uuid: str) -> bytes: |
| 333 | try: |
| 334 | row = self.conn.execute("SELECT content FROM blob WHERE uuid=?", (blob_uuid,)).fetchone() |
| 335 | if not row or not row[0]: |
| 336 | return b"" |
| 337 | return _decompress_blob(row[0]) |
| 338 | except sqlite3.OperationalError: |
| 339 | return b"" |
| 340 | |
| 341 | # --- Tickets --- |
| 342 | |
| 343 | def get_tickets(self, status: str | None = None, limit: int = 50) -> list[TicketEntry]: |
| 344 | sql = "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority FROM ticket" |
| 345 | params: list = [] |
| 346 | if status: |
| 347 | sql += " WHERE status = ?" |
| 348 | params.append(status) |
| 349 | sql += " ORDER BY tkt_ctime DESC LIMIT ?" |
| 350 | params.append(limit) |
| 351 | |
| 352 | entries = [] |
| 353 | try: |
| 354 | for row in self.conn.execute(sql, params): |
| 355 | entries.append( |
| 356 | TicketEntry( |
| 357 | uuid=row["tkt_uuid"] or "", |
| 358 | title=row["title"] or "", |
| 359 | status=row["status"] or "", |
| 360 | type=row["type"] or "", |
| 361 | created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC), |
| 362 | owner="", |
| 363 | subsystem=row["subsystem"] or "", |
| 364 | priority=row["priority"] or "", |
| 365 | ) |
| 366 | ) |
| 367 | except sqlite3.OperationalError: |
| 368 | pass |
| 369 | return entries |
| 370 | |
| 371 | def get_ticket_detail(self, uuid: str) -> TicketEntry | None: |
| 372 | try: |
| 373 | row = self.conn.execute( |
| 374 | "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority " |
| 375 | "FROM ticket WHERE tkt_uuid LIKE ?", |
| 376 | (uuid + "%",), |
| 377 | ).fetchone() |
| 378 | if not row: |
| 379 | return None |
| 380 | return TicketEntry( |
| 381 | uuid=row["tkt_uuid"], |
| 382 | title=row["title"] or "", |
| 383 | status=row["status"] or "", |
| 384 | type=row["type"] or "", |
| 385 | created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC), |
| 386 | owner="", |
| 387 | subsystem=row["subsystem"] or "", |
| 388 | priority=row["priority"] or "", |
| 389 | ) |
| 390 | except sqlite3.OperationalError: |
| 391 | return None |
| 392 | |
| 393 | # --- Wiki --- |
| 394 | |
| 395 | def get_wiki_pages(self) -> list[WikiPage]: |
| 396 | pages = [] |
| 397 | try: |
| 398 | rows = self.conn.execute( |
| 399 | """ |
| 400 | SELECT substr(tag.tagname, 6) as name, event.mtime, event.user |
| 401 | FROM tag |
| 402 | JOIN tagxref ON tag.tagid = tagxref.tagid |
| 403 | JOIN event ON tagxref.rid = event.objid |
| 404 | WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w' |
| 405 | GROUP BY tag.tagname |
| 406 | HAVING event.mtime = MAX(event.mtime) |
| 407 | ORDER BY name |
| 408 | """ |
| 409 | ).fetchall() |
| 410 | for row in rows: |
| 411 | pages.append( |
| 412 | WikiPage( |
| 413 | name=row["name"], |
| 414 | content="", |
| 415 | last_modified=_julian_to_datetime(row["mtime"]), |
| 416 | user=row["user"] or "", |
| 417 | ) |
| 418 | ) |
| 419 | except sqlite3.OperationalError: |
| 420 | pass |
| 421 | return pages |
| 422 | |
| 423 | def get_wiki_page(self, name: str) -> WikiPage | None: |
| 424 | try: |
| 425 | row = self.conn.execute( |
| 426 | """ |
| 427 | SELECT tagxref.rid, event.mtime, event.user |
| 428 | FROM tag |
| 429 | JOIN tagxref ON tag.tagid = tagxref.tagid |
| 430 | JOIN event ON tagxref.rid = event.objid |
| 431 | WHERE tag.tagname = ? AND event.type = 'w' |
| 432 | ORDER BY event.mtime DESC |
| 433 | LIMIT 1 |
| 434 | """, |
| 435 | (f"wiki-{name}",), |
| 436 | ).fetchone() |
| 437 | if not row: |
| 438 | return None |
| 439 | |
| 440 | # Read the wiki content from the blob |
| 441 | blob_row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (row["rid"],)).fetchone() |
| 442 | content = "" |
| 443 | if blob_row and blob_row[0]: |
| 444 | raw = _decompress_blob(blob_row[0]) |
| 445 | text = raw.decode("utf-8", errors="replace") |
| 446 | # Fossil wiki artifact format: header cards (D/L/P/U) then W <size>\n<content>\nZ <hash> |
| 447 | content = _extract_wiki_content(text) |
| 448 | |
| 449 | return WikiPage( |
| 450 | name=name, |
| 451 | content=content, |
| 452 | last_modified=_julian_to_datetime(row["mtime"]), |
| 453 | user=row["user"] or "", |
| 454 | ) |
| 455 | except sqlite3.OperationalError: |
| 456 | return None |
| 457 | |
| 458 | # --- Forum --- |
| 459 | |
| 460 | def get_forum_posts(self, limit: int = 50) -> list[ForumPost]: |
| 461 | posts = [] |
| 462 | try: |
| 463 | rows = self.conn.execute( |
| 464 | """ |
| 465 | SELECT blob.uuid, event.mtime, event.user, event.comment |
| 466 | FROM event |
| 467 | JOIN blob ON event.objid = blob.rid |
| 468 | WHERE event.type = 'f' |
| 469 | ORDER BY event.mtime DESC |
| 470 | LIMIT ? |
| 471 | """, |
| 472 | (limit,), |
| 473 | ).fetchall() |
| 474 | for row in rows: |
| 475 | posts.append( |
| 476 | ForumPost( |
| 477 | uuid=row["uuid"], |
| 478 | title=row["comment"] or "", |
| 479 | body="", |
| 480 | timestamp=_julian_to_datetime(row["mtime"]), |
| 481 | user=row["user"] or "", |
| 482 | ) |
| 483 | ) |
| 484 | except sqlite3.OperationalError: |
| 485 | pass |
| 486 | return posts |
| 487 | |
| 488 | def get_forum_thread(self, root_uuid: str) -> list[ForumPost]: |
| 489 | # Forum threads in Fossil are linked via the forumpost table |
| 490 | posts = [] |
| 491 | try: |
| 492 | rows = self.conn.execute( |
| 493 | """ |
| 494 | SELECT blob.uuid, event.mtime, event.user, event.comment |
| 495 | FROM event |
| 496 | JOIN blob ON event.objid = blob.rid |
| 497 | WHERE event.type = 'f' |
| 498 | ORDER BY event.mtime ASC |
| 499 | """ |
| 500 | ).fetchall() |
| 501 | for row in rows: |
| 502 | posts.append( |
| 503 | ForumPost( |
| 504 | uuid=row["uuid"], |
| 505 | title=row["comment"] or "", |
| 506 | body="", |
| 507 | timestamp=_julian_to_datetime(row["mtime"]), |
| 508 | user=row["user"] or "", |
| 509 | ) |
| 510 | ) |
| 511 | except sqlite3.OperationalError: |
| 512 | pass |
| 513 | return posts |
| --- a/fossil/signals.py | ||
| +++ b/fossil/signals.py | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +"""Auto-create FossilRepository when a Project is created.""" | |
| 2 | + | |
| 3 | +import logging | |
| 4 | + | |
| 5 | +from django.db.models.signals import post_save | |
| 6 | +from django.dispatch import receiver | |
| 7 | + | |
| 8 | +from projects.models import Project | |
| 9 | + | |
| 10 | +logger = logging.getLogger(__name__) | |
| 11 | + | |
| 12 | + | |
| 13 | +@receiver(post_save, sender=Project) | |
| 14 | +def create_fossil_repo(sender, instance, created, **kwargs): | |
| 15 | + """When a new Project is created, create a FossilRepository record and init the .fossil file.""" | |
| 16 | + if not created: | |
| 17 | + return | |
| 18 | + | |
| 19 | + from fossil.models import FossilRepository | |
| 20 | + | |
| 21 | + if FossilRepository.objects.filter(project=instance).exists(): | |
| 22 | + return | |
| 23 | + | |
| 24 | + filename = f"{instance.slug}.fossil" | |
| 25 | + repo = FossilRepository.objects.create( | |
| 26 | + project=instance, | |
| 27 | + filename=filename, | |
| 28 | + created_by=instance.created_by, | |
| 29 | + ) | |
| 30 | + | |
| 31 | + # Try to init the .fossil file on disk | |
| 32 | + try: | |
| 33 | +dated_at: | |
| 34 | + try: | |
| 35 | +cli = FossilCLI() | |
| 36 | + = FossilCLI() | |
| 37 | + if cli.icli.init(rrepo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0 | |
| 38 | + isk else 0 | |
| 39 | + repo.save(update_fields=["file_size_bytes", "updated_atelse: | |
| 40 | + logger.except Exception: | |
| 41 | + ept Exception: | |
| 42 | + logger. |
| --- a/fossil/signals.py | |
| +++ b/fossil/signals.py | |
| @@ -0,0 +1,42 @@ | |
| --- a/fossil/signals.py | |
| +++ b/fossil/signals.py | |
| @@ -0,0 +1,42 @@ | |
| 1 | """Auto-create FossilRepository when a Project is created.""" |
| 2 | |
| 3 | import logging |
| 4 | |
| 5 | from django.db.models.signals import post_save |
| 6 | from django.dispatch import receiver |
| 7 | |
| 8 | from projects.models import Project |
| 9 | |
| 10 | logger = logging.getLogger(__name__) |
| 11 | |
| 12 | |
| 13 | @receiver(post_save, sender=Project) |
| 14 | def create_fossil_repo(sender, instance, created, **kwargs): |
| 15 | """When a new Project is created, create a FossilRepository record and init the .fossil file.""" |
| 16 | if not created: |
| 17 | return |
| 18 | |
| 19 | from fossil.models import FossilRepository |
| 20 | |
| 21 | if FossilRepository.objects.filter(project=instance).exists(): |
| 22 | return |
| 23 | |
| 24 | filename = f"{instance.slug}.fossil" |
| 25 | repo = FossilRepository.objects.create( |
| 26 | project=instance, |
| 27 | filename=filename, |
| 28 | created_by=instance.created_by, |
| 29 | ) |
| 30 | |
| 31 | # Try to init the .fossil file on disk |
| 32 | try: |
| 33 | dated_at: |
| 34 | try: |
| 35 | cli = FossilCLI() |
| 36 | = FossilCLI() |
| 37 | if cli.icli.init(rrepo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0 |
| 38 | isk else 0 |
| 39 | repo.save(update_fields=["file_size_bytes", "updated_atelse: |
| 40 | logger.except Exception: |
| 41 | ept Exception: |
| 42 | logger. |
| --- a/fossil/tasks.py | ||
| +++ b/fossil/tasks.py | ||
| @@ -0,0 +1,68 @@ | ||
| 1 | +"""Celery tasks for Fossil repository management.""" | |
| 2 | + | |
| 3 | +import hashlib | |
| 4 | +import logging | |
| 5 | + | |
| 6 | +from celery import shared_task | |
| 7 | +from django.core.files.base import ContentFile | |
| 8 | + | |
| 9 | +logger = logging.getLogger(__name__) | |
| 10 | + | |
| 11 | + | |
| 12 | +@shared_task(name="fossil.sync_metadata") | |
| 13 | +def sync_repository_metadata(): | |
| 14 | + """Update metadata for all FossilRepository records from disk.""" | |
| 15 | + from fossil.models import FossilRepository | |
| 16 | + from fossil.reader import FossilReader | |
| 17 | + | |
| 18 | + for repo in FossilRepository.objects.all(): | |
| 19 | + if not repo.exists_on_disk: | |
| 20 | + continue | |
| 21 | + try: | |
| 22 | + repo.file_size_bytes = repo.full_path.stat().st_size | |
| 23 | + with FossilReader(repo.full_path) as reader: | |
| 24 | + repo.checkin_count = reader.get_checkin_count() | |
| 25 | + timeline = reader.get_timeline(limit=1) | |
| 26 | + if timeline: | |
| 27 | + repo.last_checkin_at = timeline[0].timestamp | |
| 28 | + repo.fossil_project_code = reader.get_project_code() | |
| 29 | + repo.save(update_fields=["file_size_bytes", "checkin_count", "last_checkin_at", "fossil_project_code", "updated_at", "version"]) | |
| 30 | + except Exception: | |
| 31 | + logger.exception("Failed to sync metadata for %s", repo.filename) | |
| 32 | + | |
| 33 | + | |
| 34 | +@shared_task(name="fossil.create_snapshot") | |
| 35 | +def create_snapshot(repository_id: int, note: str = ""): | |
| 36 | + """Create a FossilSnapshot if FOSSIL_STORE_IN_DB is enabled.""" | |
| 37 | + from constance import config | |
| 38 | + | |
| 39 | + if not config.FOSSIL_STORE_IN_DB: | |
| 40 | + return | |
| 41 | + | |
| 42 | + from fossil.models import FossilRepository, FossilSnapshot | |
| 43 | + | |
| 44 | + try: | |
| 45 | + repo = FossilRepository.objects.get(pk=repository_id) | |
| 46 | + except FossilRepository.DoesNotExist: | |
| 47 | + return | |
| 48 | + | |
| 49 | + if not repo.exists_on_disk: | |
| 50 | + return | |
| 51 | + | |
| 52 | + data = repo.full_path.read_bytes() | |
| 53 | + sha = hashlib.sha256(data).hexdigest() | |
| 54 | + | |
| 55 | + # Skip if latest snapshot has same hash | |
| 56 | + latest = repo.snapshots.first() | |
| 57 | + if latest and latest.fossil_hash == sha: | |
| 58 | + return | |
| 59 | + | |
| 60 | + snapshot = FossilSnapshot( | |
| 61 | + repository=repo, | |
| 62 | + file_size_bytes=len(data), | |
| 63 | + fossil_hash=sha, | |
| 64 | + note=note, | |
| 65 | + created_by=repo.created_by, | |
| 66 | + ) | |
| 67 | + snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True) | |
| 68 | + logger.info("Created snapshot for %s (hash: %s)", repo.filename, sha[:8]) |
| --- a/fossil/tasks.py | |
| +++ b/fossil/tasks.py | |
| @@ -0,0 +1,68 @@ | |
| --- a/fossil/tasks.py | |
| +++ b/fossil/tasks.py | |
| @@ -0,0 +1,68 @@ | |
| 1 | """Celery tasks for Fossil repository management.""" |
| 2 | |
| 3 | import hashlib |
| 4 | import logging |
| 5 | |
| 6 | from celery import shared_task |
| 7 | from django.core.files.base import ContentFile |
| 8 | |
| 9 | logger = logging.getLogger(__name__) |
| 10 | |
| 11 | |
| 12 | @shared_task(name="fossil.sync_metadata") |
| 13 | def sync_repository_metadata(): |
| 14 | """Update metadata for all FossilRepository records from disk.""" |
| 15 | from fossil.models import FossilRepository |
| 16 | from fossil.reader import FossilReader |
| 17 | |
| 18 | for repo in FossilRepository.objects.all(): |
| 19 | if not repo.exists_on_disk: |
| 20 | continue |
| 21 | try: |
| 22 | repo.file_size_bytes = repo.full_path.stat().st_size |
| 23 | with FossilReader(repo.full_path) as reader: |
| 24 | repo.checkin_count = reader.get_checkin_count() |
| 25 | timeline = reader.get_timeline(limit=1) |
| 26 | if timeline: |
| 27 | repo.last_checkin_at = timeline[0].timestamp |
| 28 | repo.fossil_project_code = reader.get_project_code() |
| 29 | repo.save(update_fields=["file_size_bytes", "checkin_count", "last_checkin_at", "fossil_project_code", "updated_at", "version"]) |
| 30 | except Exception: |
| 31 | logger.exception("Failed to sync metadata for %s", repo.filename) |
| 32 | |
| 33 | |
| 34 | @shared_task(name="fossil.create_snapshot") |
| 35 | def create_snapshot(repository_id: int, note: str = ""): |
| 36 | """Create a FossilSnapshot if FOSSIL_STORE_IN_DB is enabled.""" |
| 37 | from constance import config |
| 38 | |
| 39 | if not config.FOSSIL_STORE_IN_DB: |
| 40 | return |
| 41 | |
| 42 | from fossil.models import FossilRepository, FossilSnapshot |
| 43 | |
| 44 | try: |
| 45 | repo = FossilRepository.objects.get(pk=repository_id) |
| 46 | except FossilRepository.DoesNotExist: |
| 47 | return |
| 48 | |
| 49 | if not repo.exists_on_disk: |
| 50 | return |
| 51 | |
| 52 | data = repo.full_path.read_bytes() |
| 53 | sha = hashlib.sha256(data).hexdigest() |
| 54 | |
| 55 | # Skip if latest snapshot has same hash |
| 56 | latest = repo.snapshots.first() |
| 57 | if latest and latest.fossil_hash == sha: |
| 58 | return |
| 59 | |
| 60 | snapshot = FossilSnapshot( |
| 61 | repository=repo, |
| 62 | file_size_bytes=len(data), |
| 63 | fossil_hash=sha, |
| 64 | note=note, |
| 65 | created_by=repo.created_by, |
| 66 | ) |
| 67 | snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True) |
| 68 | logger.info("Created snapshot for %s (hash: %s)", repo.filename, sha[:8]) |
| --- a/fossil/urls.py | ||
| +++ b/fossil/urls.py | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +from django.urls import path | |
| 2 | + | |
| 3 | +from . import views | |
| 4 | + | |
| 5 | +app_name = "fossil" | |
| 6 | + | |
| 7 | +urlpatterns = [ | |
| 8 | + path("code/", views.code_browser, name="code"), | |
| 9 | + path("code/<path:filepath>", views.code_file, name="code_file"), | |
| 10 | + path("timeline/", views.timeline, name="timeline"), | |
| 11 | + path("tickets/", views.ticket_list, name="tickets"), | |
| 12 | + path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"), | |
| 13 | + path("wiki/", views.wiki_list, name="wiki"), | |
| 14 | + path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"), | |
| 15 | + path("forum/", views.forum_list, name="forum"), | |
| 16 | + path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"), | |
| 17 | +] |
| --- a/fossil/urls.py | |
| +++ b/fossil/urls.py | |
| @@ -0,0 +1,17 @@ | |
| --- a/fossil/urls.py | |
| +++ b/fossil/urls.py | |
| @@ -0,0 +1,17 @@ | |
| 1 | from django.urls import path |
| 2 | |
| 3 | from . import views |
| 4 | |
| 5 | app_name = "fossil" |
| 6 | |
| 7 | urlpatterns = [ |
| 8 | path("code/", views.code_browser, name="code"), |
| 9 | path("code/<path:filepath>", views.code_file, name="code_file"), |
| 10 | path("timeline/", views.timeline, name="timeline"), |
| 11 | path("tickets/", views.ticket_list, name="tickets"), |
| 12 | path("tickets/<str:ticket_uuid>/", views.ticket_detail, name="ticket_detail"), |
| 13 | path("wiki/", views.wiki_list, name="wiki"), |
| 14 | path("wiki/page/<path:page_name>", views.wiki_page, name="wiki_page"), |
| 15 | path("forum/", views.forum_list, name="forum"), |
| 16 | path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"), |
| 17 | ] |
| --- a/fossil/views.py | ||
| +++ b/fossil/views.py | ||
| @@ -0,0 +1,426 @@ | ||
| 1 | +import markdown as md | |
| 2 | +from django.contrib.auth.decorators import login_required | |
| 3 | +from django.http import Http404 | |
| 4 | +from django.shortcuts import get_object_or_404, render | |
| 5 | +from django.utils.safestring import mark_safe | |
| 6 | + | |
| 7 | +from core.permissions import P | |
| 8 | +from projects.models import Project | |
| 9 | + | |
| 10 | +from .models import FossilRepository | |
| 11 | +from .reader import FossilReader | |
| 12 | + | |
| 13 | + | |
| 14 | +def _get_repo_and_reader(slug): | |
| 15 | + """Return (project, fossil_repo, reader) or raise 404.""" | |
| 16 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 17 | + fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) | |
| 18 | + if not fossil_repo.exists_on_disk: | |
| 19 | + raise Http404("Repository file not found on disk") | |
| 20 | + reader = FossilReader(fossil_repo.full_path) | |
| 21 | + return project, fossil_repo, reader | |
| 22 | + | |
| 23 | + | |
| 24 | +# --- Code Browser --- | |
| 25 | + | |
| 26 | + | |
| 27 | +@login_required | |
| 28 | +def code_browser(request, slug): | |
| 29 | + P.PROJECT_VIEW.check(request.user) | |
| 30 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 31 | + | |
| 32 | + with reader: | |
| 33 | + checkin_uuid = reader.get_latest_checkin_uuid() | |
| 34 | + files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else [] | |
| 35 | + metadata = reader.get_metadata() | |
| 36 | + latest_commit = reader.get_timeline(limit=1, event_type="ci") | |
| 37 | + | |
| 38 | + # Build directory tree from flat file list | |
| 39 | + tree = _build_file_tree(files) | |
| 40 | + | |
| 41 | + if request.headers.get("HX-Request"): | |
| 42 | + return render(request, "fossil/partials/file_tree.html", {"tree": tree, "project": project}) | |
| 43 | + | |
| 44 | + return render( | |
| 45 | + request, | |
| 46 | + "fossil/code_browser.html", | |
| 47 | + { | |
| 48 | + "project": project, | |
| 49 | + "fossil_repo": fossil_repo, | |
| 50 | + "tree": tree, | |
| 51 | + "checkin_uuid": checkin_uuid, | |
| 52 | + "metadata": metadata, | |
| 53 | + "latest_commit": latest_commit[0] if latest_commit else None, | |
| 54 | + "active_tab": "code", | |
| 55 | + }, | |
| 56 | + ) | |
| 57 | + | |
| 58 | + | |
| 59 | +@login_required | |
| 60 | +def code_file(request, slug, filepath): | |
| 61 | + P.PROJECT_VIEW.check(request.user) | |
| 62 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 63 | + | |
| 64 | + with reader: | |
| 65 | + checkin_uuid = reader.get_latest_checkin_uuid() | |
| 66 | + files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else [] | |
| 67 | + | |
| 68 | + # Find the file by path | |
| 69 | + target = None | |
| 70 | + for f in files: | |
| 71 | + if f.name == filepath: | |
| 72 | + target = f | |
| 73 | + break | |
| 74 | + | |
| 75 | + if not target: | |
| 76 | + raise Http404(f"File not found: {filepath}") | |
| 77 | + | |
| 78 | + content_bytes = reader.get_file_content(target.uuid) | |
| 79 | + | |
| 80 | + # Try to decode as text | |
| 81 | + try: | |
| 82 | + content = content_bytes.decode("utf-8") | |
| 83 | + is_binary = False | |
| 84 | + except UnicodeDecodeError: | |
| 85 | + content = f"Binary file ({len(content_bytes)} bytes)" | |
| 86 | + is_binary = True | |
| 87 | + | |
| 88 | + # Determine language for syntax highlighting | |
| 89 | + ext = filepath.rsplit(".", 1)[-1] if "." in filepath else "" | |
| 90 | + | |
| 91 | + return render( | |
| 92 | + request, | |
| 93 | + "fossil/code_file.html", | |
| 94 | + { | |
| 95 | + "project": project, | |
| 96 | + "fossil_repo": fossil_repo, | |
| 97 | + "filepath": filepath, | |
| 98 | + "content": content, | |
| 99 | + "is_binary": is_binary, | |
| 100 | + "language": ext, | |
| 101 | + "active_tab": "code", | |
| 102 | + }, | |
| 103 | + ) | |
| 104 | + | |
| 105 | + | |
| 106 | +# --- Timeline --- | |
| 107 | + | |
| 108 | + | |
| 109 | +@login_required | |
| 110 | +def timeline(request, slug): | |
| 111 | + P.PROJECT_VIEW.check(request.user) | |
| 112 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 113 | + | |
| 114 | + event_type = request.GET.get("type", "") | |
| 115 | + page = int(request.GET.get("page", "1")) | |
| 116 | + per_page = 50 | |
| 117 | + offset = (page - 1) * per_page | |
| 118 | + | |
| 119 | + with reader: | |
| 120 | + entries = reader.get_timeline(limit=per_page, offset=offset, event_type=event_type or None) | |
| 121 | + | |
| 122 | + # Compute graph data for template | |
| 123 | + graph_entries = _compute_dag_graph(entries) | |
| 124 | + | |
| 125 | + if request.headers.get("HX-Request"): | |
| 126 | + return render(request, "fossil/partials/timeline_entries.html", {"entries": graph_entries, "project": project}) | |
| 127 | + | |
| 128 | + return render( | |
| 129 | + request, | |
| 130 | + "fossil/timeline.html", | |
| 131 | + { | |
| 132 | + "project": project, | |
| 133 | + "fossil_repo": fossil_repo, | |
| 134 | + "entries": graph_entries, | |
| 135 | + "event_type": event_type, | |
| 136 | + "page": page, | |
| 137 | + "active_tab": "timeline", | |
| 138 | + }, | |
| 139 | + ) | |
| 140 | + | |
| 141 | + | |
| 142 | +# --- Tickets --- | |
| 143 | + | |
| 144 | + | |
| 145 | +@login_required | |
| 146 | +def ticket_list(request, slug): | |
| 147 | + P.PROJECT_VIEW.check(request.user) | |
| 148 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 149 | + | |
| 150 | + status_filter = request.GET.get("status", "") | |
| 151 | + search = request.GET.get("search", "").strip() | |
| 152 | + | |
| 153 | + with reader: | |
| 154 | + tickets = reader.get_tickets(status=status_filter or None) | |
| 155 | + | |
| 156 | + if search: | |
| 157 | + tickets = [t for t in tickets if search.lower() in t.title.lower()] | |
| 158 | + | |
| 159 | + if request.headers.get("HX-Request"): | |
| 160 | + return render(request, "fossil/partials/ticket_table.html", {"tickets": tickets, "project": project}) | |
| 161 | + | |
| 162 | + return render( | |
| 163 | + request, | |
| 164 | + "fossil/ticket_list.html", | |
| 165 | + { | |
| 166 | + "project": project, | |
| 167 | + "fossil_repo": fossil_repo, | |
| 168 | + "tickets": tickets, | |
| 169 | + "status_filter": status_filter, | |
| 170 | + "search": search, | |
| 171 | + "active_tab": "tickets", | |
| 172 | + }, | |
| 173 | + ) | |
| 174 | + | |
| 175 | + | |
| 176 | +@login_required | |
| 177 | +def ticket_detail(request, slug, ticket_uuid): | |
| 178 | + P.PROJECT_VIEW.check(request.user) | |
| 179 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 180 | + | |
| 181 | + with reader: | |
| 182 | + ticket = reader.get_ticket_detail(ticket_uuid) | |
| 183 | + | |
| 184 | + if not ticket: | |
| 185 | + raise Http404("Ticket not found") | |
| 186 | + | |
| 187 | + return render( | |
| 188 | + request, | |
| 189 | + "fossil/ticket_detail.html", | |
| 190 | + { | |
| 191 | + "project": project, | |
| 192 | + "fossil_repo": fossil_repo, | |
| 193 | + "ticket": ticket, | |
| 194 | + "active_tab": "tickets", | |
| 195 | + }, | |
| 196 | + ) | |
| 197 | + | |
| 198 | + | |
| 199 | +# --- Wiki --- | |
| 200 | + | |
| 201 | + | |
| 202 | +@login_required | |
| 203 | +def wiki_list(request, slug): | |
| 204 | + P.PROJECT_VIEW.check(request.user) | |
| 205 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 206 | + | |
| 207 | + with reader: | |
| 208 | + pages = reader.get_wiki_pages() | |
| 209 | + home_page = reader.get_wiki_page("Home") | |
| 210 | + | |
| 211 | + home_content_html = "" | |
| 212 | + if home_page: | |
| 213 | + home_content_html = mark_safe(md.markdown(home_page.content, extensions=["fenced_code", "tables", "toc"])) | |
| 214 | + | |
| 215 | + return render( | |
| 216 | + request, | |
| 217 | + "fossil/wiki_list.html", | |
| 218 | + { | |
| 219 | + "project": project, | |
| 220 | + "fossil_repo": fossil_repo, | |
| 221 | + "pages": pages, | |
| 222 | + "home_page": home_page, | |
| 223 | + "home_content_html": home_content_html, | |
| 224 | + "active_tab": "wiki", | |
| 225 | + }, | |
| 226 | + ) | |
| 227 | + | |
| 228 | + | |
| 229 | +@login_required | |
| 230 | +def wiki_page(request, slug, page_name): | |
| 231 | + P.PROJECT_VIEW.check(request.user) | |
| 232 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 233 | + | |
| 234 | + with reader: | |
| 235 | + page = reader.get_wiki_page(page_name) | |
| 236 | + all_pages = reader.get_wiki_pages() | |
| 237 | + | |
| 238 | + if not page: | |
| 239 | + raise Http404(f"Wiki page not found: {page_name}") | |
| 240 | + | |
| 241 | + content_html = mark_safe(md.markdown(page.content, extensions=["fenced_code", "tables", "toc"])) | |
| 242 | + | |
| 243 | + return render( | |
| 244 | + request, | |
| 245 | + "fossil/wiki_page.html", | |
| 246 | + { | |
| 247 | + "project": project, | |
| 248 | + "fossil_repo": fossil_repo, | |
| 249 | + "page": page, | |
| 250 | + "all_pages": all_pages, | |
| 251 | + "content_html": content_html, | |
| 252 | + "active_tab": "wiki", | |
| 253 | + }, | |
| 254 | + ) | |
| 255 | + | |
| 256 | + | |
| 257 | +# --- Forum --- | |
| 258 | + | |
| 259 | + | |
| 260 | +@login_required | |
| 261 | +def forum_list(request, slug): | |
| 262 | + P.PROJECT_VIEW.check(request.user) | |
| 263 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 264 | + | |
| 265 | + with reader: | |
| 266 | + posts = reader.get_forum_posts() | |
| 267 | + | |
| 268 | + return render( | |
| 269 | + request, | |
| 270 | + "fossil/forum_list.html", | |
| 271 | + { | |
| 272 | + "project": project, | |
| 273 | + "fossil_repo": fossil_repo, | |
| 274 | + "posts": posts, | |
| 275 | + "active_tab": "forum", | |
| 276 | + }, | |
| 277 | + ) | |
| 278 | + | |
| 279 | + | |
| 280 | +@login_required | |
| 281 | +def forum_thread(request, slug, thread_uuid): | |
| 282 | + P.PROJECT_VIEW.check(request.user) | |
| 283 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 284 | + | |
| 285 | + with reader: | |
| 286 | + posts = reader.get_forum_thread(thread_uuid) | |
| 287 | + | |
| 288 | + if not posts: | |
| 289 | + raise Http404("Forum thread not found") | |
| 290 | + | |
| 291 | + return render( | |
| 292 | + request, | |
| 293 | + "fossil/forum_thread.html", | |
| 294 | + { | |
| 295 | + "project": project, | |
| 296 | + "fossil_repo": fossil_repo, | |
| 297 | + "posts": posts, | |
| 298 | + "thread_uuid": thread_uuid, | |
| 299 | + "active_tab": "forum", | |
| 300 | + }, | |
| 301 | + ) | |
| 302 | + | |
| 303 | + | |
| 304 | +# --- Helpers --- | |
| 305 | + | |
| 306 | + | |
| 307 | +def _build_file_tree(files): | |
| 308 | + """Build a flat sorted list for the top-level directory view (like GitHub). | |
| 309 | + | |
| 310 | + Shows directories and files at the root level only. Directories are sorted first. | |
| 311 | + Each directory gets the most recent commit info from its children. | |
| 312 | + """ | |
| 313 | + dirs = {} # dir_name -> most recent file entry | |
| 314 | + root_files = [] | |
| 315 | + | |
| 316 | + for f in files: | |
| 317 | + # Skip files with characters that break URL routing | |
| 318 | + if "\n" in f.name or "\r" in f.name or "\x00" in f.name: | |
| 319 | + continue | |
| 320 | + parts = f.name.split("/") | |
| 321 | + if len(parts) > 1: | |
| 322 | + # File is inside a directory | |
| 323 | + dir_name = parts[0] | |
| 324 | + if dir_name not in dirs or ( | |
| 325 | + f.last_commit_time and (not dirs[dir_name].last_commit_time or f.last_commit_time > dirs[dir_name].last_commit_time) | |
| 326 | + ): | |
| 327 | + dirs[dir_name] = f | |
| 328 | + else: | |
| 329 | + root_files.append(f) | |
| 330 | + | |
| 331 | + entries = [] | |
| 332 | + # Directories first (sorted) | |
| 333 | + for dir_name in sorted(dirs): | |
| 334 | + f = dirs[dir_name] | |
| 335 | + entries.append( | |
| 336 | + { | |
| 337 | + "name": dir_name, | |
| 338 | + "path": dir_name, | |
| 339 | + "is_dir": True, | |
| 340 | + "commit_message": f.last_commit_message, | |
| 341 | + "commit_time": f.last_commit_time, | |
| 342 | + } | |
| 343 | + ) | |
| 344 | + # Then files (sorted) | |
| 345 | + for f in sorted(root_files, key=lambda x: x.name): | |
| 346 | + entries.append( | |
| 347 | + { | |
| 348 | + "name": f.name, | |
| 349 | + "path": f.name, | |
| 350 | + "is_dir": False, | |
| 351 | + "file": f, | |
| 352 | + "commit_message": f.last_commit_message, | |
| 353 | + "commit_time": f.last_commit_time, | |
| 354 | + } | |
| 355 | + ) | |
| 356 | + | |
| 357 | + return entries | |
| 358 | + | |
| 359 | + | |
| 360 | +def _compute_dag_graph(entries): | |
| 361 | + """Compute DAG graph positions for timeline entries. | |
| 362 | + | |
| 363 | + Returns a list of dicts wrapping each entry with graph rendering data: | |
| 364 | + - node_x: pixel x position of the node | |
| 365 | + - lines: list of (x1, x2) connections to draw between this row and the next | |
| 366 | + """ | |
| 367 | + rail_pitch = 16 # pixels between rails | |
| 368 | + rail_offset = 20 # left margin | |
| 369 | + | |
| 370 | + # Build rid-to-index lookup for connecting lines | |
| 371 | + rid_to_idx = {} | |
| 372 | + for i, entry in enumerate(entries): | |
| 373 | + rid_to_idx[entry.rid] = i | |
| 374 | + | |
| 375 | + result = [] | |
| 376 | + for i, entry in enumerate(entries): | |
| 377 | + rail = max(entry.rail, 0) if entry.rail >= 0 else 0 | |
| 378 | + node_x = rail_offset + rail * rail_pitch | |
| 379 | + | |
| 380 | + # Determine what vertical lines to draw through this row | |
| 381 | + # Active rails: any branch that has entries above and below this point | |
| 382 | + active_rails = set() | |
| 383 | + | |
| 384 | + # The current entry's rail is active if it has a parent below | |
| 385 | + if entry.event_type == "ci" and entry.parent_rid in rid_to_idx: | |
| 386 | + parent_idx = rid_to_idx[entry.parent_rid] | |
| 387 | + if parent_idx > i: # parent is below in the list (older) | |
| 388 | + active_rails.add(rail) | |
| 389 | + | |
| 390 | + # Check if any entries above connect through this row to entries below | |
| 391 | + for j in range(i): | |
| 392 | + prev = entries[j] | |
| 393 | + if prev.event_type == "ci" and prev.parent_rid in rid_to_idx: | |
| 394 | + parent_idx = rid_to_idx[prev.parent_rid] | |
| 395 | + if parent_idx > i: # parent is below this row | |
| 396 | + prev_rail = max(prev.rail, 0) | |
| 397 | + active_rails.add(prev_rail) | |
| 398 | + | |
| 399 | + # Compute line segments as pixel positions | |
| 400 | + lines = [{"x": rail_offset + r * rail_pitch} for r in sorted(active_rails)] | |
| 401 | + | |
| 402 | + # Connection from this node's rail to parent's rail (if different = branch/merge line) | |
| 403 | + connector = None | |
| 404 | + if entry.event_type == "ci" and entry.parent_rid in rid_to_idx: | |
| 405 | + parent_idx = rid_to_idx[entry.parent_rid] | |
| 406 | + if parent_idx == i + 1: # immediate next entry | |
| 407 | + parent_rail = max(entries[parent_idx].rail, 0) | |
| 408 | + if parent_rail != rail: | |
| 409 | + parent_x = rail_offset + parent_rail * rail_pitch | |
| 410 | + connector = { | |
| 411 | + "left": min(node_x, parent_x), | |
| 412 | + "width": abs(node_x - parent_x), | |
| 413 | + } | |
| 414 | + | |
| 415 | + max_rail = max((e.rail for e in entries if e.rail >= 0), default=0) | |
| 416 | + result.append( | |
| 417 | + { | |
| 418 | + "entry": entry, | |
| 419 | + "node_x": node_x, | |
| 420 | + "lines": lines, | |
| 421 | + "connector": connector, | |
| 422 | + "graph_width": rail_offset + (max_rail + 2) * rail_pitch, | |
| 423 | + } | |
| 424 | + ) | |
| 425 | + | |
| 426 | + return result |
| --- a/fossil/views.py | |
| +++ b/fossil/views.py | |
| @@ -0,0 +1,426 @@ | |
| --- a/fossil/views.py | |
| +++ b/fossil/views.py | |
| @@ -0,0 +1,426 @@ | |
| 1 | import markdown as md |
| 2 | from django.contrib.auth.decorators import login_required |
| 3 | from django.http import Http404 |
| 4 | from django.shortcuts import get_object_or_404, render |
| 5 | from django.utils.safestring import mark_safe |
| 6 | |
| 7 | from core.permissions import P |
| 8 | from projects.models import Project |
| 9 | |
| 10 | from .models import FossilRepository |
| 11 | from .reader import FossilReader |
| 12 | |
| 13 | |
| 14 | def _get_repo_and_reader(slug): |
| 15 | """Return (project, fossil_repo, reader) or raise 404.""" |
| 16 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 17 | fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 18 | if not fossil_repo.exists_on_disk: |
| 19 | raise Http404("Repository file not found on disk") |
| 20 | reader = FossilReader(fossil_repo.full_path) |
| 21 | return project, fossil_repo, reader |
| 22 | |
| 23 | |
| 24 | # --- Code Browser --- |
| 25 | |
| 26 | |
| 27 | @login_required |
| 28 | def code_browser(request, slug): |
| 29 | P.PROJECT_VIEW.check(request.user) |
| 30 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 31 | |
| 32 | with reader: |
| 33 | checkin_uuid = reader.get_latest_checkin_uuid() |
| 34 | files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else [] |
| 35 | metadata = reader.get_metadata() |
| 36 | latest_commit = reader.get_timeline(limit=1, event_type="ci") |
| 37 | |
| 38 | # Build directory tree from flat file list |
| 39 | tree = _build_file_tree(files) |
| 40 | |
| 41 | if request.headers.get("HX-Request"): |
| 42 | return render(request, "fossil/partials/file_tree.html", {"tree": tree, "project": project}) |
| 43 | |
| 44 | return render( |
| 45 | request, |
| 46 | "fossil/code_browser.html", |
| 47 | { |
| 48 | "project": project, |
| 49 | "fossil_repo": fossil_repo, |
| 50 | "tree": tree, |
| 51 | "checkin_uuid": checkin_uuid, |
| 52 | "metadata": metadata, |
| 53 | "latest_commit": latest_commit[0] if latest_commit else None, |
| 54 | "active_tab": "code", |
| 55 | }, |
| 56 | ) |
| 57 | |
| 58 | |
| 59 | @login_required |
| 60 | def code_file(request, slug, filepath): |
| 61 | P.PROJECT_VIEW.check(request.user) |
| 62 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 63 | |
| 64 | with reader: |
| 65 | checkin_uuid = reader.get_latest_checkin_uuid() |
| 66 | files = reader.get_files_at_checkin(checkin_uuid) if checkin_uuid else [] |
| 67 | |
| 68 | # Find the file by path |
| 69 | target = None |
| 70 | for f in files: |
| 71 | if f.name == filepath: |
| 72 | target = f |
| 73 | break |
| 74 | |
| 75 | if not target: |
| 76 | raise Http404(f"File not found: {filepath}") |
| 77 | |
| 78 | content_bytes = reader.get_file_content(target.uuid) |
| 79 | |
| 80 | # Try to decode as text |
| 81 | try: |
| 82 | content = content_bytes.decode("utf-8") |
| 83 | is_binary = False |
| 84 | except UnicodeDecodeError: |
| 85 | content = f"Binary file ({len(content_bytes)} bytes)" |
| 86 | is_binary = True |
| 87 | |
| 88 | # Determine language for syntax highlighting |
| 89 | ext = filepath.rsplit(".", 1)[-1] if "." in filepath else "" |
| 90 | |
| 91 | return render( |
| 92 | request, |
| 93 | "fossil/code_file.html", |
| 94 | { |
| 95 | "project": project, |
| 96 | "fossil_repo": fossil_repo, |
| 97 | "filepath": filepath, |
| 98 | "content": content, |
| 99 | "is_binary": is_binary, |
| 100 | "language": ext, |
| 101 | "active_tab": "code", |
| 102 | }, |
| 103 | ) |
| 104 | |
| 105 | |
| 106 | # --- Timeline --- |
| 107 | |
| 108 | |
| 109 | @login_required |
| 110 | def timeline(request, slug): |
| 111 | P.PROJECT_VIEW.check(request.user) |
| 112 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 113 | |
| 114 | event_type = request.GET.get("type", "") |
| 115 | page = int(request.GET.get("page", "1")) |
| 116 | per_page = 50 |
| 117 | offset = (page - 1) * per_page |
| 118 | |
| 119 | with reader: |
| 120 | entries = reader.get_timeline(limit=per_page, offset=offset, event_type=event_type or None) |
| 121 | |
| 122 | # Compute graph data for template |
| 123 | graph_entries = _compute_dag_graph(entries) |
| 124 | |
| 125 | if request.headers.get("HX-Request"): |
| 126 | return render(request, "fossil/partials/timeline_entries.html", {"entries": graph_entries, "project": project}) |
| 127 | |
| 128 | return render( |
| 129 | request, |
| 130 | "fossil/timeline.html", |
| 131 | { |
| 132 | "project": project, |
| 133 | "fossil_repo": fossil_repo, |
| 134 | "entries": graph_entries, |
| 135 | "event_type": event_type, |
| 136 | "page": page, |
| 137 | "active_tab": "timeline", |
| 138 | }, |
| 139 | ) |
| 140 | |
| 141 | |
| 142 | # --- Tickets --- |
| 143 | |
| 144 | |
| 145 | @login_required |
| 146 | def ticket_list(request, slug): |
| 147 | P.PROJECT_VIEW.check(request.user) |
| 148 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 149 | |
| 150 | status_filter = request.GET.get("status", "") |
| 151 | search = request.GET.get("search", "").strip() |
| 152 | |
| 153 | with reader: |
| 154 | tickets = reader.get_tickets(status=status_filter or None) |
| 155 | |
| 156 | if search: |
| 157 | tickets = [t for t in tickets if search.lower() in t.title.lower()] |
| 158 | |
| 159 | if request.headers.get("HX-Request"): |
| 160 | return render(request, "fossil/partials/ticket_table.html", {"tickets": tickets, "project": project}) |
| 161 | |
| 162 | return render( |
| 163 | request, |
| 164 | "fossil/ticket_list.html", |
| 165 | { |
| 166 | "project": project, |
| 167 | "fossil_repo": fossil_repo, |
| 168 | "tickets": tickets, |
| 169 | "status_filter": status_filter, |
| 170 | "search": search, |
| 171 | "active_tab": "tickets", |
| 172 | }, |
| 173 | ) |
| 174 | |
| 175 | |
| 176 | @login_required |
| 177 | def ticket_detail(request, slug, ticket_uuid): |
| 178 | P.PROJECT_VIEW.check(request.user) |
| 179 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 180 | |
| 181 | with reader: |
| 182 | ticket = reader.get_ticket_detail(ticket_uuid) |
| 183 | |
| 184 | if not ticket: |
| 185 | raise Http404("Ticket not found") |
| 186 | |
| 187 | return render( |
| 188 | request, |
| 189 | "fossil/ticket_detail.html", |
| 190 | { |
| 191 | "project": project, |
| 192 | "fossil_repo": fossil_repo, |
| 193 | "ticket": ticket, |
| 194 | "active_tab": "tickets", |
| 195 | }, |
| 196 | ) |
| 197 | |
| 198 | |
| 199 | # --- Wiki --- |
| 200 | |
| 201 | |
| 202 | @login_required |
| 203 | def wiki_list(request, slug): |
| 204 | P.PROJECT_VIEW.check(request.user) |
| 205 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 206 | |
| 207 | with reader: |
| 208 | pages = reader.get_wiki_pages() |
| 209 | home_page = reader.get_wiki_page("Home") |
| 210 | |
| 211 | home_content_html = "" |
| 212 | if home_page: |
| 213 | home_content_html = mark_safe(md.markdown(home_page.content, extensions=["fenced_code", "tables", "toc"])) |
| 214 | |
| 215 | return render( |
| 216 | request, |
| 217 | "fossil/wiki_list.html", |
| 218 | { |
| 219 | "project": project, |
| 220 | "fossil_repo": fossil_repo, |
| 221 | "pages": pages, |
| 222 | "home_page": home_page, |
| 223 | "home_content_html": home_content_html, |
| 224 | "active_tab": "wiki", |
| 225 | }, |
| 226 | ) |
| 227 | |
| 228 | |
| 229 | @login_required |
| 230 | def wiki_page(request, slug, page_name): |
| 231 | P.PROJECT_VIEW.check(request.user) |
| 232 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 233 | |
| 234 | with reader: |
| 235 | page = reader.get_wiki_page(page_name) |
| 236 | all_pages = reader.get_wiki_pages() |
| 237 | |
| 238 | if not page: |
| 239 | raise Http404(f"Wiki page not found: {page_name}") |
| 240 | |
| 241 | content_html = mark_safe(md.markdown(page.content, extensions=["fenced_code", "tables", "toc"])) |
| 242 | |
| 243 | return render( |
| 244 | request, |
| 245 | "fossil/wiki_page.html", |
| 246 | { |
| 247 | "project": project, |
| 248 | "fossil_repo": fossil_repo, |
| 249 | "page": page, |
| 250 | "all_pages": all_pages, |
| 251 | "content_html": content_html, |
| 252 | "active_tab": "wiki", |
| 253 | }, |
| 254 | ) |
| 255 | |
| 256 | |
| 257 | # --- Forum --- |
| 258 | |
| 259 | |
| 260 | @login_required |
| 261 | def forum_list(request, slug): |
| 262 | P.PROJECT_VIEW.check(request.user) |
| 263 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 264 | |
| 265 | with reader: |
| 266 | posts = reader.get_forum_posts() |
| 267 | |
| 268 | return render( |
| 269 | request, |
| 270 | "fossil/forum_list.html", |
| 271 | { |
| 272 | "project": project, |
| 273 | "fossil_repo": fossil_repo, |
| 274 | "posts": posts, |
| 275 | "active_tab": "forum", |
| 276 | }, |
| 277 | ) |
| 278 | |
| 279 | |
| 280 | @login_required |
| 281 | def forum_thread(request, slug, thread_uuid): |
| 282 | P.PROJECT_VIEW.check(request.user) |
| 283 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 284 | |
| 285 | with reader: |
| 286 | posts = reader.get_forum_thread(thread_uuid) |
| 287 | |
| 288 | if not posts: |
| 289 | raise Http404("Forum thread not found") |
| 290 | |
| 291 | return render( |
| 292 | request, |
| 293 | "fossil/forum_thread.html", |
| 294 | { |
| 295 | "project": project, |
| 296 | "fossil_repo": fossil_repo, |
| 297 | "posts": posts, |
| 298 | "thread_uuid": thread_uuid, |
| 299 | "active_tab": "forum", |
| 300 | }, |
| 301 | ) |
| 302 | |
| 303 | |
| 304 | # --- Helpers --- |
| 305 | |
| 306 | |
| 307 | def _build_file_tree(files): |
| 308 | """Build a flat sorted list for the top-level directory view (like GitHub). |
| 309 | |
| 310 | Shows directories and files at the root level only. Directories are sorted first. |
| 311 | Each directory gets the most recent commit info from its children. |
| 312 | """ |
| 313 | dirs = {} # dir_name -> most recent file entry |
| 314 | root_files = [] |
| 315 | |
| 316 | for f in files: |
| 317 | # Skip files with characters that break URL routing |
| 318 | if "\n" in f.name or "\r" in f.name or "\x00" in f.name: |
| 319 | continue |
| 320 | parts = f.name.split("/") |
| 321 | if len(parts) > 1: |
| 322 | # File is inside a directory |
| 323 | dir_name = parts[0] |
| 324 | if dir_name not in dirs or ( |
| 325 | f.last_commit_time and (not dirs[dir_name].last_commit_time or f.last_commit_time > dirs[dir_name].last_commit_time) |
| 326 | ): |
| 327 | dirs[dir_name] = f |
| 328 | else: |
| 329 | root_files.append(f) |
| 330 | |
| 331 | entries = [] |
| 332 | # Directories first (sorted) |
| 333 | for dir_name in sorted(dirs): |
| 334 | f = dirs[dir_name] |
| 335 | entries.append( |
| 336 | { |
| 337 | "name": dir_name, |
| 338 | "path": dir_name, |
| 339 | "is_dir": True, |
| 340 | "commit_message": f.last_commit_message, |
| 341 | "commit_time": f.last_commit_time, |
| 342 | } |
| 343 | ) |
| 344 | # Then files (sorted) |
| 345 | for f in sorted(root_files, key=lambda x: x.name): |
| 346 | entries.append( |
| 347 | { |
| 348 | "name": f.name, |
| 349 | "path": f.name, |
| 350 | "is_dir": False, |
| 351 | "file": f, |
| 352 | "commit_message": f.last_commit_message, |
| 353 | "commit_time": f.last_commit_time, |
| 354 | } |
| 355 | ) |
| 356 | |
| 357 | return entries |
| 358 | |
| 359 | |
| 360 | def _compute_dag_graph(entries): |
| 361 | """Compute DAG graph positions for timeline entries. |
| 362 | |
| 363 | Returns a list of dicts wrapping each entry with graph rendering data: |
| 364 | - node_x: pixel x position of the node |
| 365 | - lines: list of (x1, x2) connections to draw between this row and the next |
| 366 | """ |
| 367 | rail_pitch = 16 # pixels between rails |
| 368 | rail_offset = 20 # left margin |
| 369 | |
| 370 | # Build rid-to-index lookup for connecting lines |
| 371 | rid_to_idx = {} |
| 372 | for i, entry in enumerate(entries): |
| 373 | rid_to_idx[entry.rid] = i |
| 374 | |
| 375 | result = [] |
| 376 | for i, entry in enumerate(entries): |
| 377 | rail = max(entry.rail, 0) if entry.rail >= 0 else 0 |
| 378 | node_x = rail_offset + rail * rail_pitch |
| 379 | |
| 380 | # Determine what vertical lines to draw through this row |
| 381 | # Active rails: any branch that has entries above and below this point |
| 382 | active_rails = set() |
| 383 | |
| 384 | # The current entry's rail is active if it has a parent below |
| 385 | if entry.event_type == "ci" and entry.parent_rid in rid_to_idx: |
| 386 | parent_idx = rid_to_idx[entry.parent_rid] |
| 387 | if parent_idx > i: # parent is below in the list (older) |
| 388 | active_rails.add(rail) |
| 389 | |
| 390 | # Check if any entries above connect through this row to entries below |
| 391 | for j in range(i): |
| 392 | prev = entries[j] |
| 393 | if prev.event_type == "ci" and prev.parent_rid in rid_to_idx: |
| 394 | parent_idx = rid_to_idx[prev.parent_rid] |
| 395 | if parent_idx > i: # parent is below this row |
| 396 | prev_rail = max(prev.rail, 0) |
| 397 | active_rails.add(prev_rail) |
| 398 | |
| 399 | # Compute line segments as pixel positions |
| 400 | lines = [{"x": rail_offset + r * rail_pitch} for r in sorted(active_rails)] |
| 401 | |
| 402 | # Connection from this node's rail to parent's rail (if different = branch/merge line) |
| 403 | connector = None |
| 404 | if entry.event_type == "ci" and entry.parent_rid in rid_to_idx: |
| 405 | parent_idx = rid_to_idx[entry.parent_rid] |
| 406 | if parent_idx == i + 1: # immediate next entry |
| 407 | parent_rail = max(entries[parent_idx].rail, 0) |
| 408 | if parent_rail != rail: |
| 409 | parent_x = rail_offset + parent_rail * rail_pitch |
| 410 | connector = { |
| 411 | "left": min(node_x, parent_x), |
| 412 | "width": abs(node_x - parent_x), |
| 413 | } |
| 414 | |
| 415 | max_rail = max((e.rail for e in entries if e.rail >= 0), default=0) |
| 416 | result.append( |
| 417 | { |
| 418 | "entry": entry, |
| 419 | "node_x": node_x, |
| 420 | "lines": lines, |
| 421 | "connector": connector, |
| 422 | "graph_width": rail_offset + (max_rail + 2) * rail_pitch, |
| 423 | } |
| 424 | ) |
| 425 | |
| 426 | return result |
| --- 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 | + def test_project_delete(self, admin_client, sample_project): | |
| 77 | + response = admin_client.post(f"/projects/{sample_project.slug}/delete/") | |
| 78 | + assert response.status_code == 302 | |
| 79 | + assert Project.objects.filter(slug=sample_project.slug).count() == 0 | |
| 80 | + | |
| 81 | + def test_project_delete_denied(self, no_perm_client, sample_project): | |
| 82 | + response = no_perm_client.post(f"/projects/{sample_project.slug}/delete/") | |
| 83 | + assert response.status_code == 403 | |
| 84 | + | |
| 85 | + | |
| 86 | +@pytest.mark.django_db | |
| 87 | +class TestProjectTeamViews: | |
| 88 | + def test_project_team_add(self, admin_client, org, sample_project, admin_user): | |
| 89 | + from organization.models import Team | |
| 90 | + | |
| 91 | + new_team = Team.objects.create(name="QA Team", organizatio |
| --- a/projects/tests.py | |
| +++ b/projects/tests.py | |
| @@ -0,0 +1,91 @@ | |
| --- a/projects/tests.py | |
| +++ b/projects/tests.py | |
| @@ -0,0 +1,91 @@ | |
| 1 | import pytest |
| 2 | |
| 3 | from .models import Project, ProjectTeam |
| 4 | |
| 5 | |
| 6 | @pytest.mark.django_db |
| 7 | class TestProjectModel: |
| 8 | def test_create_project(self, org, admin_user): |
| 9 | project = Project.objects.create(name="Test Project", organization=org, created_by=admin_user) |
| 10 | assert project.slug == "test-project" |
| 11 | assert project.guid is not None |
| 12 | assert project.visibility == "private" |
| 13 | |
| 14 | def test_soft_delete_project(self, sample_project, admin_user): |
| 15 | sample_project.soft_delete(user=admin_user) |
| 16 | assert Project.objects.filter(slug=sample_project.slug).count() == 0 |
| 17 | assert Project.all_objects.filter(slug=sample_project.slug).count() == 1 |
| 18 | |
| 19 | |
| 20 | @pytest.mark.django_db |
| 21 | class TestProjectViews: |
| 22 | def test_project_list_renders(self, admin_client, sample_project): |
| 23 | response = admin_client.get("/projects/") |
| 24 | assert response.status_code == 200 |
| 25 | assert sample_project.name in response.content.decode() |
| 26 | |
| 27 | def test_project_list_htmx(self, admin_client, sample_project): |
| 28 | response = admin_client.get("/projects/", HTTP_HX_REQUEST="true") |
| 29 | assert response.status_code == 200 |
| 30 | assert b"project-table" in response.content |
| 31 | |
| 32 | def test_project_list_search(self, admin_client, sample_project): |
| 33 | response = admin_client.get("/projects/?search=Frontend") |
| 34 | assert response.status_code == 200 |
| 35 | f test_project_team_remove_import pytrt Project, ProjectTeam |
| 36 | |
| 37 | |
| 38 | @pyt403 |
| 39 | |
| 40 | def test_project_create(self, admin_client, orgimport pytest |
| 41 | |
| 42 | from .mrt Project, Projecct.slug).count() == 1 |
| 43 | |
| 44 | |
| 45 | @pytestojects/create/", {"name": "New Project", "description": "Test", "visibility": "private"}) |
| 46 | assert response.status_code == 302 |
| 47 | assert Project.objects.filter(slug="new-project").exists() |
| 48 | |
| 49 | def test_project_create_denied(self, no_perm_client, org): |
| 50 | response = no_perm_client.post("/projects/create/", {"name": "Hack"}) |
| 51 | assert response.status_code == 403 |
| 52 | |
| 53 | def test_project_detail_renders(self, admin_client, sample_project): |
| 54 | response = admin_client.get(f"/projects/{sample_project.slug}/") |
| 55 | assert response.status_code == 200 |
| 56 | assert sample_project.name in response.content.decode() |
| 57 | |
| 58 | def test_project_detail_shows_teams(self, admin_client, sample_project, sample_team): |
| 59 | response = admin_client.get(f"/projects/{sample_project.slug}/") |
| 60 | assert sample_team.name in response.content.decode() |
| 61 | |
| 62 | def test_project_update(self, admin_client, sample_project): |
| 63 | response = admin_client.post( |
| 64 | f"/projects/{sample_project.slug}/edit/", |
| 65 | {"name": "Updated Project", "description": "Updated", "visibility": "public"}, |
| 66 | ) |
| 67 | assert response.status_code == 302 |
| 68 | sample_project.refresh_from_db() |
| 69 | assert sample_project.name == "Updated Project" |
| 70 | assert sample_project.visibility == "public" |
| 71 | |
| 72 | def test_project_update_denied(self, no_perm_client, sample_project): |
| 73 | response = no_perm_client.post(f"/projects/{sample_project.slug}/edit/", {"name": "Hacked"}) |
| 74 | assert response.status_code == 403 |
| 75 | |
| 76 | def test_project_delete(self, admin_client, sample_project): |
| 77 | response = admin_client.post(f"/projects/{sample_project.slug}/delete/") |
| 78 | assert response.status_code == 302 |
| 79 | assert Project.objects.filter(slug=sample_project.slug).count() == 0 |
| 80 | |
| 81 | def test_project_delete_denied(self, no_perm_client, sample_project): |
| 82 | response = no_perm_client.post(f"/projects/{sample_project.slug}/delete/") |
| 83 | assert response.status_code == 403 |
| 84 | |
| 85 | |
| 86 | @pytest.mark.django_db |
| 87 | class TestProjectTeamViews: |
| 88 | def test_project_team_add(self, admin_client, org, sample_project, admin_user): |
| 89 | from organization.models import Team |
| 90 | |
| 91 | new_team = Team.objects.create(name="QA Team", organizatio |
| --- a/projects/urls.py | ||
| +++ b/projects/urls.py | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +from django.urls import path | |
| 2 | + | |
| 3 | +from . import views | |
| 4 | + | |
| 5 | +app_name = "projects" | |
| 6 | + | |
| 7 | +urlpatterns = [ | |
| 8 | + path("", views.project_list, name="list"), | |
| 9 | + path("create/", views.project_create, name="create"), | |
| 10 | + , name="toggle_star"), | |
| 11 | + path("<slug:slug>/", views.project_detail, name="detail"), | |
| 12 | + path("<slug:slug>/edit/", views.project_update, name="update"), | |
| 13 | + path("<slug:slug>/delete/", views.project_delete, name="delete"), | |
| 14 | + path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"), | |
| 15 | + path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"), | |
| 16 | + path("<slug:slug>/teams/<slug:team_slug>/remove/", views.project_team_remo |
| --- a/projects/urls.py | |
| +++ b/projects/urls.py | |
| @@ -0,0 +1,16 @@ | |
| --- a/projects/urls.py | |
| +++ b/projects/urls.py | |
| @@ -0,0 +1,16 @@ | |
| 1 | from django.urls import path |
| 2 | |
| 3 | from . import views |
| 4 | |
| 5 | app_name = "projects" |
| 6 | |
| 7 | urlpatterns = [ |
| 8 | path("", views.project_list, name="list"), |
| 9 | path("create/", views.project_create, name="create"), |
| 10 | , name="toggle_star"), |
| 11 | path("<slug:slug>/", views.project_detail, name="detail"), |
| 12 | path("<slug:slug>/edit/", views.project_update, name="update"), |
| 13 | path("<slug:slug>/delete/", views.project_delete, name="delete"), |
| 14 | path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"), |
| 15 | path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"), |
| 16 | path("<slug:slug>/teams/<slug:team_slug>/remove/", views.project_team_remo |
| --- a/projects/views.py | ||
| +++ b/projects/views.py | ||
| @@ -0,0 +1,152 @@ | ||
| 1 | +from django.contrib import messages | |
| 2 | +from django.contrib.auth.decorators import login_required | |
| 3 | +from django.http import HttpResponse | |
| 4 | +from django.shortcuts import get_object_or_404, redirect, render | |
| 5 | + | |
| 6 | +from core.permissions import P | |
| 7 | +from organization.models import Team | |
| 8 | +from organization.views import get_org | |
| 9 | + | |
| 10 | +from .forms import ProjectForm, ProjectTeamAddForm, ProjectTeamEditForm | |
| 11 | +from .models import Project, ProjectTeam | |
| 12 | + | |
| 13 | + | |
| 14 | +@login_required | |
| 15 | +def project_list(request): | |
| 16 | + P.PROJECT_VIEW.check(request.user) | |
| 17 | + projects = Project.objects.all() | |
| 18 | + | |
| 19 | + search = request.GET.get("search", "").strip() | |
| 20 | + if search: | |
| 21 | + projects = projects.filter(name__icontains=search) | |
| 22 | + | |
| 23 | + if request.headers.get("HX-Request"): | |
| 24 | + return render(request, "projects/partials/project_table.html", {"projects": projects}) | |
| 25 | + | |
| 26 | + return render(request, "projects/project_list.html", {"projects": projects, "search": search}) | |
| 27 | + | |
| 28 | + | |
| 29 | +@login_required | |
| 30 | +def project_create(request): | |
| 31 | + P.PROJECT_ADD.check(request.user) | |
| 32 | + org = get_org() | |
| 33 | + | |
| 34 | + if request.method == "POST": | |
| 35 | + form = ProjectForm(request.POST) | |
| 36 | + if form.is_valid(): | |
| 37 | + project = form.save(commit=False) | |
| 38 | + project.organization = org | |
| 39 | + project.created_by = request.user | |
| 40 | + project.save() | |
| 41 | + messages.success(request, f'Project "{project.name}" created.') | |
| 42 | + return redirect("projects:detail", slug=project.slug) | |
| 43 | + else: | |
| 44 | + form = ProjectForm() | |
| 45 | + | |
| 46 | + return render(request, "projects/project_form.html", {"form": form, "title": "New Project"}) | |
| 47 | + | |
| 48 | + | |
| 49 | +@login_required | |
| 50 | +def project_detail(request, slug): | |
| 51 | + P.PROJECT_VIEW.check(request.user) | |
| 52 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 53 | + project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team") | |
| 54 | + return render(request, "projects/project_detail.html", {"project": project, "project_teams": project_teams}) | |
| 55 | + | |
| 56 | + | |
| 57 | +@login_required | |
| 58 | +def project_update(request, slug): | |
| 59 | + P.PROJECT_CHANGE.check(request.user) | |
| 60 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 61 | + | |
| 62 | + if request.method == "POST": | |
| 63 | + form = ProjectForm(request.POST, instance=project) | |
| 64 | + if form.is_valid(): | |
| 65 | + project = form.save(commit=False) | |
| 66 | + project.updated_by = request.user | |
| 67 | + project.save() | |
| 68 | + messages.success(request, f'Project "{project.name}" updated.') | |
| 69 | + return redirect("projects:detail", slug=project.slug) | |
| 70 | + else: | |
| 71 | + form = ProjectForm(instance=project) | |
| 72 | + | |
| 73 | + return render(request, "projects/project_form.html", {"form": form, "project": project, "title": "Edit Project"}) | |
| 74 | + | |
| 75 | + | |
| 76 | +@login_required | |
| 77 | +def project_delete(request, slug): | |
| 78 | + P.PROJECT_DELETE.check(request.user) | |
| 79 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 80 | + | |
| 81 | + if request.method == "POST": | |
| 82 | + project.soft_delete(user=request.user) | |
| 83 | + messages.success(request, f'Project "{project.name}" deleted.') | |
| 84 | + | |
| 85 | + if request.headers.get("HX-Request"): | |
| 86 | + return HttpResponse(status=200, headers={"HX-Redirect": "/projects/"}) | |
| 87 | + | |
| 88 | + return redirect("projects:list") | |
| 89 | + | |
| 90 | + return render(request, "projects/project_confirm_delete.html", {"project": project}) | |
| 91 | + | |
| 92 | + | |
| 93 | +# --- Team assignment --- | |
| 94 | + | |
| 95 | + | |
| 96 | +@login_required | |
| 97 | +def project_team_add(request, slug): | |
| 98 | + P.PROJECT_CHANGE.check(request.user) | |
| 99 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 100 | + | |
| 101 | + if request.method == "POST": | |
| 102 | + form = ProjectTeamAddForm(request.POST, project=project) | |
| 103 | + if form.is_valid(): | |
| 104 | + team = form.cleaned_data["team"] | |
| 105 | + role = form.cleaned_data["role"] | |
| 106 | + ProjectTeam.objects.create(project=project, team=team, role=role, created_by=request.user) | |
| 107 | + messages.success(request, f'Team "{team.name}" added with {role} access.') | |
| 108 | + return redirect("projects:detail", slug=project.slug) | |
| 109 | + else: | |
| 110 | + form = ProjectTeamAddForm(project=project) | |
| 111 | + | |
| 112 | + return render(request, "projects/project_team_add.html", {"form": form, "project": project}) | |
| 113 | + | |
| 114 | + | |
| 115 | +@login_required | |
| 116 | +def project_team_edit(request, slug, team_slug): | |
| 117 | + P.PROJECT_CHANGE.check(request.user) | |
| 118 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 119 | + team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True) | |
| 120 | + project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True) | |
| 121 | + | |
| 122 | + if request.method == "POST": | |
| 123 | + form = ProjectTeamEditForm(request.POST) | |
| 124 | + if form.is_valid(): | |
| 125 | + project_team.role = form.cleaned_data["role"] | |
| 126 | + project_team.updated_by = request.user | |
| 127 | + project_team.save() | |
| 128 | + messages.success(request, f'Team "{team.name}" role updated to {project_team.role}.') | |
| 129 | + return redirect("projects:detail", slug=project.slug) | |
| 130 | + else: | |
| 131 | + form = ProjectTeamEditForm(initial={"role": project_team.role}) | |
| 132 | + | |
| 133 | + return render(request, "projects/project_team_edit.html", {"form": form, "project": project, "team": team}) | |
| 134 | + | |
| 135 | + | |
| 136 | +@login_required | |
| 137 | +def project_team_remove(request, slug, team_slug): | |
| 138 | + P.PROJECT_CHANGE.check(request.user) | |
| 139 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 140 | + team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True) | |
| 141 | + project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True) | |
| 142 | + | |
| 143 | + if request.method == "POST": | |
| 144 | + project_team.soft_delete(user=request.user) | |
| 145 | + messages.success(request, f'Team "{team.name}" removed from project.') | |
| 146 | + | |
| 147 | + if request.headers.get("HX-Request"): | |
| 148 | + return HttpResponse(status=200, headers={"HX-Redirect": f"/projects/{project.slug}/"}) | |
| 149 | + | |
| 150 | + return redirect("projects:detail", slug=project.slug) | |
| 151 | + | |
| 152 | + return render(request, "projects/project_team_confirm_remove.html", {"project": project, "team": team}) |
| --- a/projects/views.py | |
| +++ b/projects/views.py | |
| @@ -0,0 +1,152 @@ | |
| --- a/projects/views.py | |
| +++ b/projects/views.py | |
| @@ -0,0 +1,152 @@ | |
| 1 | from django.contrib import messages |
| 2 | from django.contrib.auth.decorators import login_required |
| 3 | from django.http import HttpResponse |
| 4 | from django.shortcuts import get_object_or_404, redirect, render |
| 5 | |
| 6 | from core.permissions import P |
| 7 | from organization.models import Team |
| 8 | from organization.views import get_org |
| 9 | |
| 10 | from .forms import ProjectForm, ProjectTeamAddForm, ProjectTeamEditForm |
| 11 | from .models import Project, ProjectTeam |
| 12 | |
| 13 | |
| 14 | @login_required |
| 15 | def project_list(request): |
| 16 | P.PROJECT_VIEW.check(request.user) |
| 17 | projects = Project.objects.all() |
| 18 | |
| 19 | search = request.GET.get("search", "").strip() |
| 20 | if search: |
| 21 | projects = projects.filter(name__icontains=search) |
| 22 | |
| 23 | if request.headers.get("HX-Request"): |
| 24 | return render(request, "projects/partials/project_table.html", {"projects": projects}) |
| 25 | |
| 26 | return render(request, "projects/project_list.html", {"projects": projects, "search": search}) |
| 27 | |
| 28 | |
| 29 | @login_required |
| 30 | def project_create(request): |
| 31 | P.PROJECT_ADD.check(request.user) |
| 32 | org = get_org() |
| 33 | |
| 34 | if request.method == "POST": |
| 35 | form = ProjectForm(request.POST) |
| 36 | if form.is_valid(): |
| 37 | project = form.save(commit=False) |
| 38 | project.organization = org |
| 39 | project.created_by = request.user |
| 40 | project.save() |
| 41 | messages.success(request, f'Project "{project.name}" created.') |
| 42 | return redirect("projects:detail", slug=project.slug) |
| 43 | else: |
| 44 | form = ProjectForm() |
| 45 | |
| 46 | return render(request, "projects/project_form.html", {"form": form, "title": "New Project"}) |
| 47 | |
| 48 | |
| 49 | @login_required |
| 50 | def project_detail(request, slug): |
| 51 | P.PROJECT_VIEW.check(request.user) |
| 52 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 53 | project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team") |
| 54 | return render(request, "projects/project_detail.html", {"project": project, "project_teams": project_teams}) |
| 55 | |
| 56 | |
| 57 | @login_required |
| 58 | def project_update(request, slug): |
| 59 | P.PROJECT_CHANGE.check(request.user) |
| 60 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 61 | |
| 62 | if request.method == "POST": |
| 63 | form = ProjectForm(request.POST, instance=project) |
| 64 | if form.is_valid(): |
| 65 | project = form.save(commit=False) |
| 66 | project.updated_by = request.user |
| 67 | project.save() |
| 68 | messages.success(request, f'Project "{project.name}" updated.') |
| 69 | return redirect("projects:detail", slug=project.slug) |
| 70 | else: |
| 71 | form = ProjectForm(instance=project) |
| 72 | |
| 73 | return render(request, "projects/project_form.html", {"form": form, "project": project, "title": "Edit Project"}) |
| 74 | |
| 75 | |
| 76 | @login_required |
| 77 | def project_delete(request, slug): |
| 78 | P.PROJECT_DELETE.check(request.user) |
| 79 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 80 | |
| 81 | if request.method == "POST": |
| 82 | project.soft_delete(user=request.user) |
| 83 | messages.success(request, f'Project "{project.name}" deleted.') |
| 84 | |
| 85 | if request.headers.get("HX-Request"): |
| 86 | return HttpResponse(status=200, headers={"HX-Redirect": "/projects/"}) |
| 87 | |
| 88 | return redirect("projects:list") |
| 89 | |
| 90 | return render(request, "projects/project_confirm_delete.html", {"project": project}) |
| 91 | |
| 92 | |
| 93 | # --- Team assignment --- |
| 94 | |
| 95 | |
| 96 | @login_required |
| 97 | def project_team_add(request, slug): |
| 98 | P.PROJECT_CHANGE.check(request.user) |
| 99 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 100 | |
| 101 | if request.method == "POST": |
| 102 | form = ProjectTeamAddForm(request.POST, project=project) |
| 103 | if form.is_valid(): |
| 104 | team = form.cleaned_data["team"] |
| 105 | role = form.cleaned_data["role"] |
| 106 | ProjectTeam.objects.create(project=project, team=team, role=role, created_by=request.user) |
| 107 | messages.success(request, f'Team "{team.name}" added with {role} access.') |
| 108 | return redirect("projects:detail", slug=project.slug) |
| 109 | else: |
| 110 | form = ProjectTeamAddForm(project=project) |
| 111 | |
| 112 | return render(request, "projects/project_team_add.html", {"form": form, "project": project}) |
| 113 | |
| 114 | |
| 115 | @login_required |
| 116 | def project_team_edit(request, slug, team_slug): |
| 117 | P.PROJECT_CHANGE.check(request.user) |
| 118 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 119 | team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True) |
| 120 | project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True) |
| 121 | |
| 122 | if request.method == "POST": |
| 123 | form = ProjectTeamEditForm(request.POST) |
| 124 | if form.is_valid(): |
| 125 | project_team.role = form.cleaned_data["role"] |
| 126 | project_team.updated_by = request.user |
| 127 | project_team.save() |
| 128 | messages.success(request, f'Team "{team.name}" role updated to {project_team.role}.') |
| 129 | return redirect("projects:detail", slug=project.slug) |
| 130 | else: |
| 131 | form = ProjectTeamEditForm(initial={"role": project_team.role}) |
| 132 | |
| 133 | return render(request, "projects/project_team_edit.html", {"form": form, "project": project, "team": team}) |
| 134 | |
| 135 | |
| 136 | @login_required |
| 137 | def project_team_remove(request, slug, team_slug): |
| 138 | P.PROJECT_CHANGE.check(request.user) |
| 139 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 140 | team = get_object_or_404(Team, slug=team_slug, deleted_at__isnull=True) |
| 141 | project_team = get_object_or_404(ProjectTeam, project=project, team=team, deleted_at__isnull=True) |
| 142 | |
| 143 | if request.method == "POST": |
| 144 | project_team.soft_delete(user=request.user) |
| 145 | messages.success(request, f'Team "{team.name}" removed from project.') |
| 146 | |
| 147 | if request.headers.get("HX-Request"): |
| 148 | return HttpResponse(status=200, headers={"HX-Redirect": f"/projects/{project.slug}/"}) |
| 149 | |
| 150 | return redirect("projects:detail", slug=project.slug) |
| 151 | |
| 152 | return render(request, "projects/project_team_confirm_remove.html", {"project": project, "team": team}) |
| --- pyproject.toml | ||
| +++ pyproject.toml | ||
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | [project] |
| 2 | -name = "fossilrepo-django-htmx" | |
| 2 | +name = "fossilrepo" | |
| 3 | 3 | version = "0.1.0" |
| 4 | -description = "Fossilrepo Django + HTMX template — server-rendered with progressive enhancement" | |
| 4 | +description = "Omnibus-style installer for a self-hosted Fossil forge." | |
| 5 | +license = "MIT" | |
| 5 | 6 | requires-python = ">=3.12" |
| 6 | 7 | dependencies = [ |
| 7 | 8 | "django>=5.1,<6.0", |
| 8 | 9 | "psycopg2-binary>=2.9", |
| 9 | 10 | "redis>=5.0", |
| @@ -20,12 +21,18 @@ | ||
| 20 | 21 | "django-cors-headers>=4.4", |
| 21 | 22 | "gunicorn>=23.0", |
| 22 | 23 | "whitenoise>=6.7", |
| 23 | 24 | "boto3>=1.35", |
| 24 | 25 | "sentry-sdk[django]>=2.14", |
| 26 | + "click>=8.1", | |
| 27 | + "rich>=13.0", | |
| 28 | + "markdown>=3.6", | |
| 25 | 29 | ] |
| 26 | 30 | |
| 31 | +[project.scripts] | |
| 32 | +fossilrepo-ctl = "ctl.main:cli" | |
| 33 | + | |
| 27 | 34 | [project.optional-dependencies] |
| 28 | 35 | dev = [ |
| 29 | 36 | "ruff>=0.7", |
| 30 | 37 | "pytest>=8.3", |
| 31 | 38 | "pytest-django>=4.9", |
| @@ -42,11 +49,11 @@ | ||
| 42 | 49 | [tool.ruff.lint] |
| 43 | 50 | select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"] |
| 44 | 51 | ignore = ["E501"] |
| 45 | 52 | |
| 46 | 53 | [tool.ruff.lint.isort] |
| 47 | -known-first-party = ["config", "core", "auth1", "organization", "items", "testdata"] | |
| 54 | +known-first-party = ["config", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "testdata", "ctl"] | |
| 48 | 55 | |
| 49 | 56 | [tool.ruff.format] |
| 50 | 57 | quote-style = "double" |
| 51 | 58 | |
| 52 | 59 | [tool.pytest.ini_options] |
| @@ -55,15 +62,18 @@ | ||
| 55 | 62 | python_classes = ["Test*"] |
| 56 | 63 | python_functions = ["test_*"] |
| 57 | 64 | addopts = "-v --tb=short --strict-markers" |
| 58 | 65 | |
| 59 | 66 | [tool.coverage.run] |
| 60 | -source = ["core", "auth1", "organization", "items"] | |
| 67 | +source = ["core", "auth1", "organization", "items", "projects", "pages", "fossil"] | |
| 61 | 68 | omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"] |
| 62 | 69 | |
| 63 | 70 | [tool.coverage.report] |
| 64 | 71 | fail_under = 80 |
| 65 | 72 | show_missing = true |
| 66 | 73 | |
| 74 | +[tool.hatch.build.targets.wheel] | |
| 75 | +packages = ["ctl", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "config"] | |
| 76 | + | |
| 67 | 77 | [build-system] |
| 68 | 78 | requires = ["hatchling"] |
| 69 | -build-backend = "hatchling.backends" | |
| 79 | +build-backend = "hatchling.build" | |
| 70 | 80 |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -1,9 +1,10 @@ | |
| 1 | [project] |
| 2 | name = "fossilrepo-django-htmx" |
| 3 | version = "0.1.0" |
| 4 | description = "Fossilrepo Django + HTMX template — server-rendered with progressive enhancement" |
| 5 | requires-python = ">=3.12" |
| 6 | dependencies = [ |
| 7 | "django>=5.1,<6.0", |
| 8 | "psycopg2-binary>=2.9", |
| 9 | "redis>=5.0", |
| @@ -20,12 +21,18 @@ | |
| 20 | "django-cors-headers>=4.4", |
| 21 | "gunicorn>=23.0", |
| 22 | "whitenoise>=6.7", |
| 23 | "boto3>=1.35", |
| 24 | "sentry-sdk[django]>=2.14", |
| 25 | ] |
| 26 | |
| 27 | [project.optional-dependencies] |
| 28 | dev = [ |
| 29 | "ruff>=0.7", |
| 30 | "pytest>=8.3", |
| 31 | "pytest-django>=4.9", |
| @@ -42,11 +49,11 @@ | |
| 42 | [tool.ruff.lint] |
| 43 | select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"] |
| 44 | ignore = ["E501"] |
| 45 | |
| 46 | [tool.ruff.lint.isort] |
| 47 | known-first-party = ["config", "core", "auth1", "organization", "items", "testdata"] |
| 48 | |
| 49 | [tool.ruff.format] |
| 50 | quote-style = "double" |
| 51 | |
| 52 | [tool.pytest.ini_options] |
| @@ -55,15 +62,18 @@ | |
| 55 | python_classes = ["Test*"] |
| 56 | python_functions = ["test_*"] |
| 57 | addopts = "-v --tb=short --strict-markers" |
| 58 | |
| 59 | [tool.coverage.run] |
| 60 | source = ["core", "auth1", "organization", "items"] |
| 61 | omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"] |
| 62 | |
| 63 | [tool.coverage.report] |
| 64 | fail_under = 80 |
| 65 | show_missing = true |
| 66 | |
| 67 | [build-system] |
| 68 | requires = ["hatchling"] |
| 69 | build-backend = "hatchling.backends" |
| 70 |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -1,9 +1,10 @@ | |
| 1 | [project] |
| 2 | name = "fossilrepo" |
| 3 | version = "0.1.0" |
| 4 | description = "Omnibus-style installer for a self-hosted Fossil forge." |
| 5 | license = "MIT" |
| 6 | requires-python = ">=3.12" |
| 7 | dependencies = [ |
| 8 | "django>=5.1,<6.0", |
| 9 | "psycopg2-binary>=2.9", |
| 10 | "redis>=5.0", |
| @@ -20,12 +21,18 @@ | |
| 21 | "django-cors-headers>=4.4", |
| 22 | "gunicorn>=23.0", |
| 23 | "whitenoise>=6.7", |
| 24 | "boto3>=1.35", |
| 25 | "sentry-sdk[django]>=2.14", |
| 26 | "click>=8.1", |
| 27 | "rich>=13.0", |
| 28 | "markdown>=3.6", |
| 29 | ] |
| 30 | |
| 31 | [project.scripts] |
| 32 | fossilrepo-ctl = "ctl.main:cli" |
| 33 | |
| 34 | [project.optional-dependencies] |
| 35 | dev = [ |
| 36 | "ruff>=0.7", |
| 37 | "pytest>=8.3", |
| 38 | "pytest-django>=4.9", |
| @@ -42,11 +49,11 @@ | |
| 49 | [tool.ruff.lint] |
| 50 | select = ["E", "F", "I", "W", "UP", "B", "SIM", "N"] |
| 51 | ignore = ["E501"] |
| 52 | |
| 53 | [tool.ruff.lint.isort] |
| 54 | known-first-party = ["config", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "testdata", "ctl"] |
| 55 | |
| 56 | [tool.ruff.format] |
| 57 | quote-style = "double" |
| 58 | |
| 59 | [tool.pytest.ini_options] |
| @@ -55,15 +62,18 @@ | |
| 62 | python_classes = ["Test*"] |
| 63 | python_functions = ["test_*"] |
| 64 | addopts = "-v --tb=short --strict-markers" |
| 65 | |
| 66 | [tool.coverage.run] |
| 67 | source = ["core", "auth1", "organization", "items", "projects", "pages", "fossil"] |
| 68 | omit = ["*/migrations/*", "*/tests/*", "*/testdata/*", "manage.py", "startup.py"] |
| 69 | |
| 70 | [tool.coverage.report] |
| 71 | fail_under = 80 |
| 72 | show_missing = true |
| 73 | |
| 74 | [tool.hatch.build.targets.wheel] |
| 75 | packages = ["ctl", "core", "auth1", "organization", "items", "projects", "pages", "fossil", "config"] |
| 76 | |
| 77 | [build-system] |
| 78 | requires = ["hatchling"] |
| 79 | build-backend = "hatchling.build" |
| 80 |
| --- static/admin/css/dark_theme.css | ||
| +++ static/admin/css/dark_theme.css | ||
| @@ -365,14 +365,15 @@ | ||
| 365 | 365 | |
| 366 | 366 | .module h2, |
| 367 | 367 | .module caption, |
| 368 | 368 | .inline-group h2 { |
| 369 | 369 | background: var(--darkened-bg); |
| 370 | - color: var(--body-quiet-color); | |
| 370 | + color: var(--body-loud-color); | |
| 371 | 371 | border-left: 3px solid var(--primary); |
| 372 | 372 | padding-left: 12px; |
| 373 | 373 | font-size: 0.75em; |
| 374 | + font-weight: 600; | |
| 374 | 375 | text-transform: uppercase; |
| 375 | 376 | letter-spacing: 0.06em; |
| 376 | 377 | } |
| 377 | 378 | |
| 378 | 379 | .module { |
| 379 | 380 | |
| 380 | 381 | ADDED static/admin/img/logo-dark.png |
| --- static/admin/css/dark_theme.css | |
| +++ static/admin/css/dark_theme.css | |
| @@ -365,14 +365,15 @@ | |
| 365 | |
| 366 | .module h2, |
| 367 | .module caption, |
| 368 | .inline-group h2 { |
| 369 | background: var(--darkened-bg); |
| 370 | color: var(--body-quiet-color); |
| 371 | border-left: 3px solid var(--primary); |
| 372 | padding-left: 12px; |
| 373 | font-size: 0.75em; |
| 374 | text-transform: uppercase; |
| 375 | letter-spacing: 0.06em; |
| 376 | } |
| 377 | |
| 378 | .module { |
| 379 | |
| 380 | DDED static/admin/img/logo-dark.png |
| --- static/admin/css/dark_theme.css | |
| +++ static/admin/css/dark_theme.css | |
| @@ -365,14 +365,15 @@ | |
| 365 | |
| 366 | .module h2, |
| 367 | .module caption, |
| 368 | .inline-group h2 { |
| 369 | background: var(--darkened-bg); |
| 370 | color: var(--body-loud-color); |
| 371 | border-left: 3px solid var(--primary); |
| 372 | padding-left: 12px; |
| 373 | font-size: 0.75em; |
| 374 | font-weight: 600; |
| 375 | text-transform: uppercase; |
| 376 | letter-spacing: 0.06em; |
| 377 | } |
| 378 | |
| 379 | .module { |
| 380 | |
| 381 | DDED static/admin/img/logo-dark.png |
Binary file
| --- static/img/fossilrepo-logo-dark.png | ||
| +++ static/img/fossilrepo-logo-dark.png | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- static/img/fossilrepo-logo-dark.png | |
| +++ static/img/fossilrepo-logo-dark.png | |
| 0 | annot compute difference between binary files |
| 1 |
| --- static/img/fossilrepo-logo-dark.png | |
| +++ static/img/fossilrepo-logo-dark.png | |
| 0 | annot compute difference between binary files |
| 1 |
| --- static/img/fossilrepo-logo-dark.svg | ||
| +++ static/img/fossilrepo-logo-dark.svg | ||
| @@ -1,110 +1,13 @@ | ||
| 1 | -<svg width="1033" height="213" viewBox="0 0 1033 213" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | -<g filter="url(#filter0_d_303_614)"> | |
| 3 | -<rect x="4" width="1025" height="205" fill="#2B2D2C"/> | |
| 4 | -<path d="M491.56 167H500.064V163.116H495.59V132.884H500.138V129H491.56V167Z" fill="#DB394C"/> | |
| 5 | -<path d="M506.018 129V132.884H510.529V163.116H505.981V167H514.56V129H506.018Z" fill="#DB394C"/> | |
| 6 | -<path d="M505.093 139.932H501.026V155.788H505.093V139.932Z" fill="#DB394C"/> | |
| 7 | -<path d="M148.624 101.062C168.554 83.5327 166.323 44.969 144.11 30.1725C134.17 22.9805 119.767 19.0365 107.519 19.1396C101.434 19.6293 95.7028 22.4134 90.7074 26.048C84.6217 30.5592 79.7024 35.9209 72.9573 40.2774C65.1219 44.969 54.1422 50.4597 44.0754 49.2223C37.3303 47.3921 37.1782 40.8703 37.4825 34.9929C37.4825 34.9414 37.4825 34.8641 37.4825 34.8125C37.2796 34.8125 37.1275 34.8641 36.9246 34.8641C34.4903 34.8641 32.4871 34.1681 30.8896 33.0854C30.8135 33.7041 30.7374 34.3485 30.7374 34.9929C29.6724 46.129 34.2367 55.873 46.2814 56.3628C61.9776 56.5175 77.0145 46.7734 88.0956 36.9005C97.5793 27.9556 106.125 23.3156 119.006 26.7956C132.522 29.1929 144.871 36.1014 151.311 48.6294C160.034 65.849 155.216 92.9158 136.477 101.01C155.216 109.079 160.034 136.171 151.311 153.391C144.871 165.919 132.395 172.518 118.879 174.915C105.947 178.395 97.63 174.09 88.0956 165.12C77.0145 155.247 61.9523 145.503 46.2814 145.657C34.2367 146.199 29.6724 155.865 30.7374 167.053C30.7374 173.523 34.5917 179.117 40.0943 181.54C40.0182 180.354 40.0943 179.194 40.3986 178.112C40.7536 176.849 41.2861 175.766 41.9454 174.812C39.2575 173.265 37.4317 170.378 37.4317 167.053C37.1275 161.201 37.2542 154.654 44.0246 152.824C54.0661 151.586 65.0459 157.077 72.9066 161.768C79.6517 166.125 84.571 171.513 90.6567 175.998C95.6521 179.633 101.383 182.417 107.469 182.906C119.716 183.009 134.119 179.065 144.059 171.873C166.272 157.103 168.529 118.539 148.573 100.984L148.624 101.062Z" fill="#DC394C"/> | |
| 8 | -<path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="#DC394C"/> | |
| 9 | -<g opacity="0.2"> | |
| 10 | -<path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="url(#paint0_linear_303_614)"/> | |
| 11 | -</g> | |
| 12 | -<path d="M37.7352 41.4374C37.4056 39.3236 37.4563 37.0294 37.5577 34.8382C37.5577 29.8889 41.5135 25.8676 46.3821 25.8676H79.5241C80.158 25.8676 80.7412 25.6356 81.223 25.2231C83.6827 23.1093 86.8523 20.7893 89.8445 19.0364H46.4074C37.8367 19.0364 30.8634 26.1253 30.8634 34.8382C30.483 38.7823 30.8381 42.5716 31.9538 45.8196C33.3991 43.7574 35.4277 42.1076 37.7606 41.4632L37.7352 41.4374Z" fill="url(#paint1_linear_303_614)"/> | |
| 13 | -<path d="M37.7352 160.402C37.4056 162.516 37.4563 164.81 37.5577 167.001C37.5577 171.951 41.5135 175.972 46.3821 175.972H79.5241C80.158 175.972 80.7412 176.204 81.223 176.617C83.6827 178.73 86.8523 181.05 89.8445 182.803H46.4074C37.8367 182.803 30.8634 175.714 30.8634 167.001C30.483 163.057 30.8381 159.268 31.9538 156.02C33.3991 158.082 35.4277 159.732 37.7606 160.376L37.7352 160.402Z" fill="url(#paint2_linear_303_614)"/> | |
| 14 | -<path d="M102.042 111.167C102.042 111.167 35.8849 111.167 30.5852 111.167V117.998H102.042C107.494 117.998 111.779 119.183 114.848 121.555C119.615 124.88 120.604 139.11 114.366 142.719C106.48 148.209 82.0859 144.781 72.8051 145.683C76.5833 147.436 80.3616 149.292 83.6327 151.483C84.5962 152.128 85.712 152.488 86.853 152.463C103.437 152.179 125.929 157.129 125.396 132.665C125.599 117.276 115.938 111.321 102.042 111.167Z" fill="url(#paint3_linear_303_614)"/> | |
| 15 | -<path d="M87.7147 49.2997C86.3454 49.2997 84.9761 49.6606 83.8097 50.4339C80.6146 52.5735 76.9125 54.3779 73.2103 56.0793C82.77 56.9815 106.226 53.5273 114.34 59.0437C120.578 62.6526 119.563 76.9078 114.822 80.2074C111.753 82.5531 107.468 83.7647 102.016 83.7647H30.5593V90.5958H102.016C115.912 90.4669 125.573 84.4865 125.37 69.0971C125.928 44.8401 104.324 49.5059 87.6893 49.2997H87.7147Z" fill="url(#paint4_linear_303_614)"/> | |
| 16 | -<path d="M121.263 126.427C120.198 126.608 119.133 126.814 118.068 127.072C119.539 132.614 118.702 140.218 114.391 142.744C106.505 148.235 82.1114 144.807 72.8306 145.709C76.6088 147.462 80.3871 149.318 83.6582 151.509C84.6217 152.153 85.7375 152.514 86.8785 152.489C103.462 152.205 125.954 157.154 125.422 132.691C125.447 130.062 125.168 127.768 124.661 125.654C123.596 126.04 122.48 126.298 121.288 126.427H121.263Z" fill="url(#paint5_linear_303_614)"/> | |
| 17 | -<g filter="url(#filter1_d_303_614)"> | |
| 18 | -<rect x="195.06" y="32.5" width="803.841" height="76.1916" stroke="#2B2D2C" shape-rendering="crispEdges"/> | |
| 19 | -<path fill-rule="evenodd" clip-rule="evenodd" d="M549.696 108.274V32.9176H582.786C588.268 32.9176 593.264 33.9167 597.661 36.0512C602.078 38.1951 605.594 41.3351 608.098 45.4472C610.637 49.6163 611.813 54.4753 611.813 59.8467C611.813 65.2685 610.6 70.1409 607.941 74.2414C606.304 76.7879 604.248 78.9243 601.812 80.6522L616.857 108.274H587.652L576.157 86.6632V108.274H549.696ZM590.127 104.154H609.921L596.291 79.1301C597.57 78.5163 598.748 77.8075 599.824 77.0036C601.677 75.6192 603.23 73.9531 604.481 72.005C606.622 68.706 607.693 64.6532 607.693 59.8467C607.693 55.0838 606.655 50.9983 604.579 47.59C602.504 44.1817 599.598 41.5709 595.862 39.7576C592.126 37.9442 587.768 37.0375 582.786 37.0375H553.816V104.154H572.037V82.0004H578.343L590.127 104.154ZM572.037 67.843H578.46C580.601 67.843 582.426 67.5808 583.933 67.0565C585.463 66.5103 586.631 65.6473 587.44 64.4675C588.27 63.2877 588.685 61.7474 588.685 59.8467C588.685 57.9241 588.27 56.3619 587.44 55.1603C586.631 53.9368 585.463 53.0411 583.933 52.473C582.426 51.8831 580.601 51.5882 578.46 51.5882H572.037V67.843ZM576.157 55.7081V63.7231H578.46C580.277 63.7231 581.612 63.499 582.563 63.171C583.38 62.8765 583.79 62.5051 584.041 62.1388L584.056 62.1176L584.071 62.0965C584.278 61.8011 584.565 61.1655 584.565 59.8467C584.565 58.4992 584.275 57.8278 584.05 57.5022L584.026 57.467L584.002 57.4314C583.73 57.019 583.305 56.6347 582.499 56.3351L582.465 56.3227L582.432 56.3096C581.53 55.9566 580.244 55.7081 578.46 55.7081H576.157Z" fill="#FAFAFA"/> | |
| 20 | -<path fill-rule="evenodd" clip-rule="evenodd" d="M475.677 108.274V32.9176H532.288V55.8391H502.138V59.1351H529.797V82.0566H502.138V85.3525H532.157V108.274H475.677ZM498.018 89.4724V77.9367H525.677V63.2549H498.018V51.7193H528.168V37.0375H479.797V104.154H528.037V89.4724H498.018Z" fill="#FAFAFA"/> | |
| 21 | -<path fill-rule="evenodd" clip-rule="evenodd" d="M406.344 108.274V32.9176H432.805V85.3525H459.94V108.274H406.344ZM428.685 89.4724V37.0375H410.464V104.154H455.82V89.4724H428.685Z" fill="#FAFAFA"/> | |
| 22 | -<path fill-rule="evenodd" clip-rule="evenodd" d="M389.337 32.9176V108.274H362.876V32.9176H389.337ZM385.217 37.0375H366.996V104.154H385.217V37.0375Z" fill="#FAFAFA"/> | |
| 23 | -<path fill-rule="evenodd" clip-rule="evenodd" d="M341.181 91.5344L341.177 91.5409C337.907 97.2938 333.433 101.742 327.775 104.771L327.768 104.775L327.762 104.778C322.179 107.742 316.001 109.192 309.313 109.192C302.581 109.192 296.376 107.731 290.786 104.738L290.776 104.733L290.767 104.728C285.138 101.676 280.682 97.2211 277.416 91.4754L277.411 91.4663L277.406 91.4573C274.097 85.5658 272.552 78.5492 272.552 70.5958C272.552 62.6044 274.095 55.5718 277.41 49.6951L277.413 49.69C280.679 43.9181 285.143 39.4637 290.789 36.4517C296.378 33.46 302.582 32 309.313 32C316.004 32 322.185 33.4617 327.769 36.4501C333.435 39.4604 337.911 43.9139 341.18 49.6886C344.519 55.5668 346.074 62.602 346.074 70.5958C346.074 78.5928 344.518 85.6372 341.181 91.5344ZM325.83 40.0853C320.892 37.4417 315.387 36.1199 309.313 36.1199C303.196 36.1199 297.668 37.4417 292.731 40.0853C287.815 42.707 283.904 46.585 280.998 51.7193C278.114 56.8317 276.672 63.1239 276.672 70.5958C276.672 78.0241 278.114 84.3054 280.998 89.4396C283.904 94.552 287.815 98.4409 292.731 101.106C297.668 103.75 303.196 105.072 309.313 105.072C315.387 105.072 320.892 103.761 325.83 101.139C330.768 98.4956 334.689 94.6176 337.595 89.5052C340.501 84.3709 341.954 78.0678 341.954 70.5958C341.954 63.1239 340.501 56.8317 337.595 51.7193C334.689 46.585 330.768 42.707 325.83 40.0853ZM317.756 61.9115L317.749 61.8931C316.943 59.7622 315.851 58.4077 314.59 57.5445L314.569 57.5305L314.549 57.5161C313.319 56.6524 311.655 56.1013 309.313 56.1013C306.973 56.1013 305.289 56.6519 304.028 57.5279L304.02 57.533C302.77 58.3979 301.668 59.7627 300.838 61.9119C300.021 64.0587 299.538 66.9113 299.538 70.5958C299.538 74.2732 300.019 77.1389 300.838 79.3129C301.662 81.4232 302.759 82.7991 304.024 83.6938C305.285 84.5503 306.973 85.0903 309.313 85.0903C311.665 85.0903 313.337 84.545 314.569 83.6941C315.854 82.8 316.95 81.4286 317.753 79.3212L317.756 79.313L317.759 79.3048C318.595 77.1376 319.088 74.2759 319.088 70.5958C319.088 66.9141 318.594 64.0686 317.763 61.9298L317.756 61.9115ZM316.916 87.08C314.862 88.5002 312.328 89.2102 309.313 89.2102C306.298 89.2102 303.753 88.5002 301.677 87.08C299.624 85.6381 298.061 83.5407 296.991 80.7879C295.942 78.0132 295.418 74.6158 295.418 70.5958C295.418 66.5758 295.942 63.1894 296.991 60.4366C298.061 57.6619 299.624 55.5645 301.677 54.1444C303.753 52.7024 306.298 51.9814 309.313 51.9814C312.328 51.9814 314.862 52.7024 316.916 54.1444C318.992 55.5645 320.554 57.6619 321.602 60.4366C322.673 63.1894 323.208 66.5758 323.208 70.5958C323.208 74.6158 322.673 78.0132 321.602 80.7879C320.554 83.5407 318.992 85.6381 316.916 87.08Z" fill="#FAFAFA"/> | |
| 24 | -<path fill-rule="evenodd" clip-rule="evenodd" d="M194.56 108.274V32.9176H227.781C233.232 32.9176 238.075 33.6472 242.173 35.2702C246.242 36.8731 249.59 39.253 251.916 42.5486C254.255 45.8309 255.365 49.6469 255.365 53.8167C255.365 56.9112 254.675 59.8338 253.244 62.5031C252.104 64.6623 250.581 66.5265 248.711 68.0855C251.447 69.7483 253.658 71.9966 255.309 74.7798C257.146 77.8531 257.987 81.3596 257.987 85.1465C257.987 89.576 256.814 93.6497 254.409 97.2336C252.037 100.802 248.706 103.516 244.595 105.431C240.412 107.379 235.595 108.274 230.271 108.274H194.56ZM244.445 66.1032C244.662 65.967 244.875 65.8259 245.084 65.68C247.029 64.3036 248.536 62.5995 249.607 60.5676C250.699 58.5358 251.245 56.2855 251.245 53.8167C251.245 50.4084 250.35 47.448 248.558 44.9355C246.788 42.423 244.156 40.4785 240.66 39.1021C237.186 37.7257 232.893 37.0375 227.781 37.0375H198.679V104.154H230.271C235.143 104.154 239.338 103.335 242.856 101.696C246.373 100.058 249.082 97.8073 250.983 94.9453C252.906 92.0832 253.867 88.8169 253.867 85.1465C253.867 81.9786 253.168 79.2257 251.77 76.888C250.371 74.5284 248.493 72.6823 246.133 71.3496C245.682 71.0907 245.222 70.8546 244.752 70.6413C242.8 69.7553 240.683 69.2613 238.401 69.1594C238.357 69.1574 238.312 69.1556 238.268 69.1539V68.4984C238.276 68.4967 238.284 68.4951 238.293 68.4934C240.602 68.0248 242.653 67.2281 244.445 66.1032ZM228.809 80.0833L228.797 80.0783C228.133 79.7907 227.138 79.5659 225.683 79.5659H221.02V85.4836H225.421C228.065 85.4836 229.319 84.9764 229.832 84.6027C230.143 84.3635 230.477 83.9794 230.477 82.7869C230.477 81.8557 230.271 81.3515 230.077 81.0582C229.849 80.7117 229.491 80.3743 228.82 80.0881L228.809 80.0833ZM232.303 87.8993C230.774 89.0354 228.48 89.6035 225.421 89.6035H216.901V75.446H225.683C227.54 75.446 229.124 75.7301 230.435 76.2981C231.768 76.8662 232.795 77.6964 233.516 78.7888C234.237 79.8812 234.597 81.2139 234.597 82.7869C234.597 85.0373 233.833 86.7414 232.303 87.8993ZM228.114 58.8396L228.119 58.8327C228.228 58.6826 228.38 58.3975 228.38 57.7493C228.38 56.7949 228.117 56.5269 227.831 56.3166L227.815 56.305L227.799 56.2933C227.174 55.8244 226.219 55.4459 224.635 55.4459H221.02V60.0527H224.372C225.538 60.0527 226.399 59.869 227.035 59.6078C227.65 59.3551 227.943 59.0772 228.109 58.8465L228.114 58.8396ZM224.372 64.1726C225.967 64.1726 227.377 63.9213 228.6 63.4188C229.824 62.9163 230.774 62.1953 231.451 61.2559C232.15 60.2946 232.5 59.1257 232.5 57.7493C232.5 55.6737 231.757 54.0898 230.271 52.9974C228.786 51.8831 226.907 51.326 224.635 51.326H216.901V64.1726H224.372Z" fill="#FAFAFA"/> | |
| 25 | -<path d="M979.577 55.6468C979.388 53.2869 978.503 51.4461 976.922 50.1245C975.365 48.8029 972.993 48.1421 969.807 48.1421C967.777 48.1421 966.114 48.3899 964.816 48.8855C963.541 49.3575 962.597 50.0065 961.984 50.8325C961.37 51.6585 961.051 52.6025 961.028 53.6645C960.981 54.5377 961.134 55.3282 961.488 56.0362C961.866 56.7206 962.456 57.346 963.258 57.9124C964.06 58.4552 965.087 58.9508 966.338 59.3992C967.589 59.8476 969.075 60.2488 970.798 60.6028L976.745 61.8771C980.757 62.7267 984.191 63.8477 987.046 65.2401C989.902 66.6325 992.238 68.2726 994.056 70.1606C995.873 72.025 997.206 74.1253 998.056 76.4617C998.929 78.7981 999.377 81.3468 999.401 84.108C999.377 88.8751 998.185 92.9107 995.826 96.2146C993.466 99.5185 990.091 102.032 985.701 103.755C981.335 105.477 976.084 106.339 969.948 106.339C963.647 106.339 958.149 105.407 953.452 103.542C948.78 101.678 945.145 98.8106 942.549 94.9402C939.977 91.0463 938.679 86.0668 938.655 80.0017H957.346C957.464 82.22 958.019 84.0844 959.01 85.5948C960.001 87.1051 961.394 88.2497 963.187 89.0285C965.004 89.8073 967.164 90.1967 969.665 90.1967C971.766 90.1967 973.524 89.9371 974.94 89.4179C976.356 88.8987 977.43 88.1789 978.161 87.2585C978.893 86.3382 979.27 85.288 979.294 84.108C979.27 82.9988 978.905 82.0312 978.197 81.2052C977.512 80.3557 976.379 79.6005 974.798 78.9397C973.217 78.2553 971.081 77.6181 968.391 77.0281L961.169 75.4705C954.75 74.0781 949.688 71.7536 945.983 68.4968C942.302 65.2165 940.473 60.7444 940.496 55.0804C940.473 50.4785 941.7 46.4548 944.178 43.0092C946.679 39.5401 950.137 36.8379 954.55 34.9028C958.986 32.9676 964.072 32 969.807 32C975.66 32 980.722 32.9794 984.993 34.9382C989.265 36.8969 992.557 39.6581 994.87 43.2216C997.206 46.7616 998.386 50.9033 998.41 55.6468H979.577Z" fill="#FAFAFA"/> | |
| 26 | -<path d="M870.211 105.489V32.9912H889.893V62.3019H890.884L912.69 32.9912H935.629L911.132 65.2755L936.195 105.489H912.69L896.406 78.3025L889.893 86.7983V105.489H870.211Z" fill="#FAFAFA"/> | |
| 27 | -<path d="M803.624 105.489V32.9912H834.917C840.298 32.9912 845.006 33.9706 849.042 35.9293C853.077 37.8881 856.216 40.7083 858.458 44.3898C860.7 48.0714 861.821 52.4845 861.821 57.6292C861.821 62.8211 860.664 67.1988 858.352 70.7624C856.063 74.3259 852.841 77.0163 848.688 78.8335C844.558 80.6506 839.732 81.5592 834.209 81.5592H815.518V66.2667H830.245C832.557 66.2667 834.528 65.9835 836.156 65.4171C837.808 64.8271 839.071 63.8949 839.944 62.6205C840.841 61.3461 841.289 59.6824 841.289 57.6292C841.289 55.5524 840.841 53.8651 839.944 52.5671C839.071 51.2455 837.808 50.2779 836.156 49.6643C834.528 49.0271 832.557 48.7085 830.245 48.7085H823.306V105.489H803.624ZM846.103 72.2138L864.228 105.489H842.847L825.147 72.2138H846.103Z" fill="#FAFAFA"/> | |
| 28 | -<path d="M794.518 69.2402C794.518 77.3113 792.948 84.1198 789.81 89.6657C786.671 95.188 782.435 99.377 777.101 102.233C771.768 105.064 765.82 106.48 759.26 106.48C752.652 106.48 746.681 105.053 741.348 102.197C736.038 99.318 731.813 95.1172 728.675 89.5949C725.56 84.049 724.002 77.2641 724.002 69.2402C724.002 61.1691 725.56 54.3725 728.675 48.8501C731.813 43.3042 736.038 39.1153 741.348 36.2833C746.681 33.4278 752.652 32 759.26 32C765.82 32 771.768 33.4278 777.101 36.2833C782.435 39.1153 786.671 43.3042 789.81 48.8501C792.948 54.3725 794.518 61.1691 794.518 69.2402ZM774.269 69.2402C774.269 64.8979 773.691 61.2399 772.535 58.2664C771.402 55.2692 769.714 53.0037 767.472 51.4697C765.254 49.9121 762.517 49.1333 759.26 49.1333C756.003 49.1333 753.254 49.9121 751.012 51.4697C748.793 53.0037 747.106 55.2692 745.95 58.2664C744.817 61.2399 744.25 64.8979 744.25 69.2402C744.25 73.5826 744.817 77.2523 745.95 80.2495C747.106 83.223 748.793 85.4886 751.012 87.0461C753.254 88.5801 756.003 89.3471 759.26 89.3471C762.517 89.3471 765.254 88.5801 767.472 87.0461C769.714 85.4886 771.402 83.223 772.535 80.2495C773.691 77.2523 774.269 73.5826 774.269 69.2402Z" fill="#FAFAFA"/> | |
| 29 | -<path d="M638.238 105.489L616.857 32.9912H638.804L648.716 77.5945H649.283L661.035 32.9912H678.31L690.063 77.7361H690.629L700.541 32.9912H722.489L701.107 105.489H682.275L669.956 64.9923H669.389L657.07 105.489H638.238Z" fill="#FAFAFA"/> | |
| 30 | -</g> | |
| 31 | -<path d="M649.525 133.05H642.505V164.911H638.725V133.05H631.704V129.81H649.525V133.05Z" fill="#FAFAFA"/> | |
| 32 | -<path d="M622.242 155.191H626.022V158.161C626.022 160.501 625.302 162.355 623.862 163.723C622.422 165.055 620.19 165.721 617.165 165.721C614.141 165.721 611.891 165.055 610.415 163.723C608.939 162.355 608.201 160.501 608.201 158.161V136.56C608.201 134.22 608.921 132.384 610.361 131.052C611.801 129.684 614.033 129 617.057 129C620.082 129 622.332 129.684 623.808 131.052C625.284 132.384 626.022 134.22 626.022 136.56V141.69H622.242V136.56C622.242 135.192 621.81 134.13 620.946 133.374C620.117 132.618 618.821 132.24 617.057 132.24C615.329 132.24 614.051 132.618 613.223 133.374C612.395 134.13 611.981 135.192 611.981 136.56V158.161C611.981 159.493 612.395 160.555 613.223 161.347C614.087 162.103 615.401 162.481 617.165 162.481C618.893 162.481 620.171 162.103 621 161.347C621.828 160.555 622.242 159.493 622.242 158.161V155.191Z" fill="#FAFAFA"/> | |
| 33 | -<path d="M594.456 164.911V129.81H598.236V164.911H594.456Z" fill="#FAFAFA"/> | |
| 34 | -<path d="M551.898 164.911V129.81H566.749V133.05H555.678V145.471H564.589V148.711H555.678V164.911H551.898ZM574.309 129.81H578.089V161.671H587.971V164.911H574.309V129.81Z" fill="#FAFAFA"/> | |
| 35 | -<path d="M521.839 164.911V129.81H525.457L537.337 156.811V129.81H541.117V164.911H537.499L525.619 137.91V164.911H521.839Z" fill="#FAFAFA"/> | |
| 36 | -<path d="M481.068 155.191H484.848V158.161C484.848 160.501 484.128 162.355 482.688 163.723C481.248 165.055 479.015 165.721 475.991 165.721C472.967 165.721 470.717 165.055 469.241 163.723C467.765 162.355 467.027 160.501 467.027 158.161V136.56C467.027 134.22 467.747 132.384 469.187 131.052C470.627 129.684 472.859 129 475.883 129C478.907 129 481.158 129.684 482.634 131.052C484.11 132.384 484.848 134.22 484.848 136.56V141.69H481.068V136.56C481.068 135.192 480.635 134.13 479.771 133.374C478.943 132.618 477.647 132.24 475.883 132.24C474.155 132.24 472.877 132.618 472.049 133.374C471.221 134.13 470.807 135.192 470.807 136.56V158.161C470.807 159.493 471.221 160.555 472.049 161.347C472.913 162.103 474.227 162.481 475.991 162.481C477.719 162.481 478.997 162.103 479.825 161.347C480.653 160.555 481.068 159.493 481.068 158.161V155.191Z" fill="#FAFAFA"/> | |
| 37 | -<path d="M435.94 164.911V152.329L427.03 129.81H431.026L437.83 147.901L444.635 129.81H448.631L439.72 152.383V164.911H435.94Z" fill="#FAFAFA"/> | |
| 38 | -<path d="M411.97 129.81C414.85 129.81 416.992 130.494 418.396 131.862C419.836 133.194 420.556 135.03 420.556 137.37V140.61C420.556 143.67 419.278 145.633 416.722 146.497C419.638 147.289 421.096 149.287 421.096 152.491V157.351C421.096 159.691 420.394 161.545 418.99 162.913C417.586 164.245 415.426 164.911 412.51 164.911H403.816V129.81H411.97ZM416.776 137.37C416.776 136.038 416.362 134.994 415.534 134.238C414.742 133.446 413.554 133.05 411.97 133.05H407.596V145.201H411.97C413.59 145.201 414.796 144.768 415.588 143.904C416.38 143.04 416.776 141.942 416.776 140.61V137.37ZM417.316 152.491C417.316 151.159 416.902 150.061 416.074 149.197C415.282 148.333 414.094 147.901 412.51 147.901H407.596V161.671H412.51C414.13 161.671 415.336 161.293 416.128 160.537C416.92 159.745 417.316 158.683 417.316 157.351V152.491Z" fill="#FAFAFA"/> | |
| 39 | -<path d="M364.106 164.911V129.81H372.8C375.68 129.81 377.822 130.494 379.226 131.862C380.666 133.194 381.386 135.03 381.386 137.37V157.351C381.386 159.691 380.684 161.545 379.28 162.913C377.876 164.245 375.716 164.911 372.8 164.911H364.106ZM377.606 137.37C377.606 136.038 377.192 134.994 376.364 134.238C375.572 133.446 374.384 133.05 372.8 133.05H367.886V161.671H372.8C374.42 161.671 375.626 161.293 376.418 160.537C377.21 159.745 377.606 158.683 377.606 157.351V137.37Z" fill="#FAFAFA"/> | |
| 40 | -<path d="M350.335 155.191H354.115V158.161C354.115 160.501 353.395 162.355 351.955 163.723C350.515 165.055 348.283 165.721 345.259 165.721C342.235 165.721 339.985 165.055 338.509 163.723C337.033 162.355 336.295 160.501 336.295 158.161V136.56C336.295 134.22 337.015 132.384 338.455 131.052C339.895 129.684 342.127 129 345.151 129C348.175 129 350.425 129.684 351.901 131.052C353.377 132.384 354.115 134.22 354.115 136.56V148.441H340.075V158.161C340.075 159.493 340.489 160.555 341.317 161.347C342.181 162.103 343.495 162.481 345.259 162.481C346.987 162.481 348.265 162.103 349.093 161.347C349.921 160.555 350.335 159.493 350.335 158.161V155.191ZM340.075 145.2H350.335V136.56C350.335 135.192 349.903 134.13 349.039 133.374C348.211 132.618 346.915 132.24 345.151 132.24C343.423 132.24 342.145 132.618 341.317 133.374C340.489 134.13 340.075 135.192 340.075 136.56V145.2Z" fill="#FAFAFA"/> | |
| 41 | -<path d="M315.483 164.911V129.81H317.103L318.669 133.806C319.173 132.438 319.857 131.304 320.721 130.404C321.585 129.468 322.737 129 324.177 129C326.265 129 327.849 129.594 328.929 130.782C330.045 131.934 330.603 133.68 330.603 136.02V139.53H326.985V135.804C326.985 134.76 326.751 133.914 326.283 133.266C325.815 132.582 325.059 132.24 324.015 132.24C323.331 132.24 322.611 132.51 321.855 133.05C321.135 133.59 320.523 134.454 320.019 135.642C319.515 136.794 319.263 138.324 319.263 140.232V164.911H315.483Z" fill="#FAFAFA"/> | |
| 42 | -<path d="M301.713 155.191H305.493V158.161C305.493 160.501 304.773 162.355 303.333 163.723C301.893 165.055 299.661 165.721 296.636 165.721C293.612 165.721 291.362 165.055 289.886 163.723C288.41 162.355 287.672 160.501 287.672 158.161V136.56C287.672 134.22 288.392 132.384 289.832 131.052C291.272 129.684 293.504 129 296.528 129C299.553 129 301.803 129.684 303.279 131.052C304.755 132.384 305.493 134.22 305.493 136.56V148.441H291.452V158.161C291.452 159.493 291.866 160.555 292.694 161.347C293.558 162.103 294.872 162.481 296.636 162.481C298.365 162.481 299.643 162.103 300.471 161.347C301.299 160.555 301.713 159.493 301.713 158.161V155.191ZM291.452 145.2H301.713V136.56C301.713 135.192 301.281 134.13 300.417 133.374C299.589 132.618 298.293 132.24 296.528 132.24C294.8 132.24 293.522 132.618 292.694 133.374C291.866 134.13 291.452 135.192 291.452 136.56V145.2Z" fill="#FAFAFA"/> | |
| 43 | -<path d="M260.684 129.81H264.464V158.161C264.464 159.493 264.878 160.555 265.706 161.347C266.534 162.103 267.776 162.481 269.432 162.481C271.124 162.481 272.384 162.103 273.212 161.347C274.04 160.555 274.454 159.493 274.454 158.161V129.81H278.235V158.161C278.235 160.501 277.479 162.355 275.966 163.723C274.49 165.055 272.312 165.721 269.432 165.721C267.776 165.721 266.39 165.433 265.274 164.857C264.158 164.281 263.258 163.525 262.574 162.589C261.926 163.561 261.026 164.335 259.874 164.911C258.722 165.451 257.336 165.721 255.716 165.721C252.836 165.721 250.64 165.055 249.128 163.723C247.652 162.355 246.914 160.501 246.914 158.161V129.81H250.694V158.161C250.694 159.493 251.108 160.555 251.936 161.347C252.8 162.103 254.06 162.481 255.716 162.481C257.408 162.481 258.65 162.103 259.442 161.347C260.27 160.555 260.684 159.493 260.684 158.161V129.81Z" fill="#FAFAFA"/> | |
| 44 | -<path d="M237.476 158.161C237.476 160.501 236.72 162.355 235.208 163.723C233.732 165.055 231.446 165.721 228.35 165.721C225.254 165.721 222.932 165.055 221.383 163.723C219.871 162.355 219.115 160.501 219.115 158.161V136.56C219.115 134.22 219.853 132.384 221.329 131.052C222.842 129.684 225.146 129 228.242 129C231.338 129 233.642 129.684 235.154 131.052C236.702 132.384 237.476 134.22 237.476 136.56V158.161ZM233.696 136.56C233.696 135.192 233.246 134.13 232.346 133.374C231.446 132.618 230.078 132.24 228.242 132.24C226.442 132.24 225.092 132.618 224.192 133.374C223.328 134.13 222.896 135.192 222.896 136.56V158.161C222.896 159.493 223.346 160.555 224.246 161.347C225.146 162.103 226.514 162.481 228.35 162.481C230.15 162.481 231.482 162.103 232.346 161.347C233.246 160.555 233.696 159.493 233.696 158.161V136.56Z" fill="#FAFAFA"/> | |
| 45 | -<path d="M194.56 164.911V129.81H202.714C205.594 129.81 207.736 130.494 209.14 131.862C210.58 133.194 211.3 135.03 211.3 137.37V146.551C211.3 148.891 210.598 150.745 209.194 152.113C207.79 153.445 205.63 154.111 202.714 154.111H198.34V164.911H194.56ZM207.52 137.37C207.52 136.038 207.106 134.994 206.278 134.238C205.486 133.446 204.298 133.05 202.714 133.05H198.34V150.871H202.714C204.334 150.871 205.54 150.493 206.332 149.737C207.124 148.945 207.52 147.883 207.52 146.551V137.37Z" fill="#FAFAFA"/> | |
| 46 | -</g> | |
| 47 | -<defs> | |
| 48 | -<filter id="filter0_d_303_614" x="0" y="0" width="1033" height="213" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> | |
| 49 | -<feFlood flood-opacity="0" result="BackgroundImageFix"/> | |
| 50 | -<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> | |
| 51 | -<feOffset dy="4"/> | |
| 52 | -<feGaussianBlur stdDeviation="2"/> | |
| 53 | -<feComposite in2="hardAlpha" operator="out"/> | |
| 54 | -<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> | |
| 55 | -<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/> | |
| 56 | -<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/> | |
| 57 | -</filter> | |
| 58 | -<filter id="filter1_d_303_614" x="190.56" y="32" width="812.841" height="85.1917" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> | |
| 59 | -<feFlood flood-opacity="0" result="BackgroundImageFix"/> | |
| 60 | -<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> | |
| 61 | -<feOffset dy="4"/> | |
| 62 | -<feGaussianBlur stdDeviation="2"/> | |
| 63 | -<feComposite in2="hardAlpha" operator="out"/> | |
| 64 | -<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> | |
| 65 | -<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/> | |
| 66 | -<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/> | |
| 67 | -</filter> | |
| 68 | -<linearGradient id="paint0_linear_303_614" x1="48.4373" y1="83.8162" x2="111.888" y2="65.7367" gradientUnits="userSpaceOnUse"> | |
| 69 | -<stop stop-color="#DD4652"/> | |
| 70 | -<stop offset="0.15" stop-color="#DD4854"/> | |
| 71 | -<stop offset="0.23" stop-color="#DE4F5B"/> | |
| 72 | -<stop offset="0.29" stop-color="#E15C67"/> | |
| 73 | -<stop offset="0.34" stop-color="#E46F78"/> | |
| 74 | -<stop offset="0.38" stop-color="#E9878F"/> | |
| 75 | -<stop offset="0.42" stop-color="#EEA5AB"/> | |
| 76 | -<stop offset="0.46" stop-color="#F5C8CC"/> | |
| 77 | -<stop offset="0.49" stop-color="#FCF0F1"/> | |
| 78 | -<stop offset="0.5" stop-color="#FEFDFD"/> | |
| 79 | -<stop offset="0.52" stop-color="#F7D4D8"/> | |
| 80 | -<stop offset="0.55" stop-color="#F1B0B7"/> | |
| 81 | -<stop offset="0.58" stop-color="#EB8F9A"/> | |
| 82 | -<stop offset="0.61" stop-color="#E67481"/> | |
| 83 | -<stop offset="0.64" stop-color="#E25E6D"/> | |
| 84 | -<stop offset="0.68" stop-color="#DF4D5E"/> | |
| 85 | -<stop offset="0.73" stop-color="#DD4153"/> | |
| 86 | -<stop offset="0.8" stop-color="#DC3A4D"/> | |
| 87 | -<stop offset="1" stop-color="#DC394C"/> | |
| 88 | -</linearGradient> | |
| 89 | -<linearGradient id="paint1_linear_303_614" x1="107.569" y1="12.2053" x2="11.6611" y2="39.2106" gradientUnits="userSpaceOnUse"> | |
| 90 | -<stop stop-color="#8B3138" stop-opacity="0"/> | |
| 91 | -<stop offset="0.65" stop-color="#DC394C"/> | |
| 92 | -</linearGradient> | |
| 93 | -<linearGradient id="paint2_linear_303_614" x1="107.569" y1="189.634" x2="11.6864" y2="162.629" gradientUnits="userSpaceOnUse"> | |
| 94 | -<stop stop-color="#8B3138" stop-opacity="0"/> | |
| 95 | -<stop offset="0.65" stop-color="#DC394C"/> | |
| 96 | -</linearGradient> | |
| 97 | -<linearGradient id="paint3_linear_303_614" x1="30.5598" y1="131.944" x2="125.396" y2="131.944" gradientUnits="userSpaceOnUse"> | |
| 98 | -<stop stop-color="#8B3138" stop-opacity="0"/> | |
| 99 | -<stop offset="0.65" stop-color="#DC394C"/> | |
| 100 | -</linearGradient> | |
| 101 | -<linearGradient id="paint4_linear_303_614" x1="30.5847" y1="69.8189" x2="125.396" y2="69.8189" gradientUnits="userSpaceOnUse"> | |
| 102 | -<stop stop-color="#8B3138" stop-opacity="0"/> | |
| 103 | -<stop offset="0.65" stop-color="#DC394C"/> | |
| 104 | -</linearGradient> | |
| 105 | -<linearGradient id="paint5_linear_303_614" x1="-48.1491" y1="140.528" x2="123.546" y2="139.131" gradientUnits="userSpaceOnUse"> | |
| 106 | -<stop stop-color="#6A1921"/> | |
| 107 | -<stop offset="1" stop-color="#DC394C"/> | |
| 108 | -</linearGradient> | |
| 109 | -</defs> | |
| 1 | +<svg width="220" height="36" viewBox="0 0 220 36" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | + <!-- Ammonite spiral icon --> | |
| 3 | + <g transform="translate(4, 2)"> | |
| 4 | + <circle cx="16" cy="16" r="14" stroke="#DC394C" stroke-width="2.5" fill="none"/> | |
| 5 | + <path d="M16 2C8.268 2 2 8.268 2 16" stroke="#DC394C" stroke-width="2.5" stroke-linecap="round" fill="none"/> | |
| 6 | + <path d="M16 7C11.029 7 7 11.029 7 16c0 4.971 4.029 9 9 9" stroke="#e8677a" stroke-width="2" stroke-linecap="round" fill="none"/> | |
| 7 | + <path d="M16 12c-2.209 0-4 1.791-4 4s1.791 4 4 4" stroke="#DC394C" stroke-width="1.5" stroke-linecap="round" fill="none"/> | |
| 8 | + <circle cx="16" cy="16" r="1.5" fill="#DC394C"/> | |
| 9 | + </g> | |
| 10 | + <!-- Wordmark --> | |
| 11 | + <text x="42" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#e5e5e5" letter-spacing="-0.5">fossil</text> | |
| 12 | + <text x="102" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#DC394C" letter-spacing="-0.5">repo</text> | |
| 110 | 13 | </svg> |
| 111 | 14 |
| --- static/img/fossilrepo-logo-dark.svg | |
| +++ static/img/fossilrepo-logo-dark.svg | |
| @@ -1,110 +1,13 @@ | |
| 1 | <svg width="1033" height="213" viewBox="0 0 1033 213" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 2 | <g filter="url(#filter0_d_303_614)"> |
| 3 | <rect x="4" width="1025" height="205" fill="#2B2D2C"/> |
| 4 | <path d="M491.56 167H500.064V163.116H495.59V132.884H500.138V129H491.56V167Z" fill="#DB394C"/> |
| 5 | <path d="M506.018 129V132.884H510.529V163.116H505.981V167H514.56V129H506.018Z" fill="#DB394C"/> |
| 6 | <path d="M505.093 139.932H501.026V155.788H505.093V139.932Z" fill="#DB394C"/> |
| 7 | <path d="M148.624 101.062C168.554 83.5327 166.323 44.969 144.11 30.1725C134.17 22.9805 119.767 19.0365 107.519 19.1396C101.434 19.6293 95.7028 22.4134 90.7074 26.048C84.6217 30.5592 79.7024 35.9209 72.9573 40.2774C65.1219 44.969 54.1422 50.4597 44.0754 49.2223C37.3303 47.3921 37.1782 40.8703 37.4825 34.9929C37.4825 34.9414 37.4825 34.8641 37.4825 34.8125C37.2796 34.8125 37.1275 34.8641 36.9246 34.8641C34.4903 34.8641 32.4871 34.1681 30.8896 33.0854C30.8135 33.7041 30.7374 34.3485 30.7374 34.9929C29.6724 46.129 34.2367 55.873 46.2814 56.3628C61.9776 56.5175 77.0145 46.7734 88.0956 36.9005C97.5793 27.9556 106.125 23.3156 119.006 26.7956C132.522 29.1929 144.871 36.1014 151.311 48.6294C160.034 65.849 155.216 92.9158 136.477 101.01C155.216 109.079 160.034 136.171 151.311 153.391C144.871 165.919 132.395 172.518 118.879 174.915C105.947 178.395 97.63 174.09 88.0956 165.12C77.0145 155.247 61.9523 145.503 46.2814 145.657C34.2367 146.199 29.6724 155.865 30.7374 167.053C30.7374 173.523 34.5917 179.117 40.0943 181.54C40.0182 180.354 40.0943 179.194 40.3986 178.112C40.7536 176.849 41.2861 175.766 41.9454 174.812C39.2575 173.265 37.4317 170.378 37.4317 167.053C37.1275 161.201 37.2542 154.654 44.0246 152.824C54.0661 151.586 65.0459 157.077 72.9066 161.768C79.6517 166.125 84.571 171.513 90.6567 175.998C95.6521 179.633 101.383 182.417 107.469 182.906C119.716 183.009 134.119 179.065 144.059 171.873C166.272 157.103 168.529 118.539 148.573 100.984L148.624 101.062Z" fill="#DC394C"/> |
| 8 | <path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="#DC394C"/> |
| 9 | <g opacity="0.2"> |
| 10 | <path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="url(#paint0_linear_303_614)"/> |
| 11 | </g> |
| 12 | <path d="M37.7352 41.4374C37.4056 39.3236 37.4563 37.0294 37.5577 34.8382C37.5577 29.8889 41.5135 25.8676 46.3821 25.8676H79.5241C80.158 25.8676 80.7412 25.6356 81.223 25.2231C83.6827 23.1093 86.8523 20.7893 89.8445 19.0364H46.4074C37.8367 19.0364 30.8634 26.1253 30.8634 34.8382C30.483 38.7823 30.8381 42.5716 31.9538 45.8196C33.3991 43.7574 35.4277 42.1076 37.7606 41.4632L37.7352 41.4374Z" fill="url(#paint1_linear_303_614)"/> |
| 13 | <path d="M37.7352 160.402C37.4056 162.516 37.4563 164.81 37.5577 167.001C37.5577 171.951 41.5135 175.972 46.3821 175.972H79.5241C80.158 175.972 80.7412 176.204 81.223 176.617C83.6827 178.73 86.8523 181.05 89.8445 182.803H46.4074C37.8367 182.803 30.8634 175.714 30.8634 167.001C30.483 163.057 30.8381 159.268 31.9538 156.02C33.3991 158.082 35.4277 159.732 37.7606 160.376L37.7352 160.402Z" fill="url(#paint2_linear_303_614)"/> |
| 14 | <path d="M102.042 111.167C102.042 111.167 35.8849 111.167 30.5852 111.167V117.998H102.042C107.494 117.998 111.779 119.183 114.848 121.555C119.615 124.88 120.604 139.11 114.366 142.719C106.48 148.209 82.0859 144.781 72.8051 145.683C76.5833 147.436 80.3616 149.292 83.6327 151.483C84.5962 152.128 85.712 152.488 86.853 152.463C103.437 152.179 125.929 157.129 125.396 132.665C125.599 117.276 115.938 111.321 102.042 111.167Z" fill="url(#paint3_linear_303_614)"/> |
| 15 | <path d="M87.7147 49.2997C86.3454 49.2997 84.9761 49.6606 83.8097 50.4339C80.6146 52.5735 76.9125 54.3779 73.2103 56.0793C82.77 56.9815 106.226 53.5273 114.34 59.0437C120.578 62.6526 119.563 76.9078 114.822 80.2074C111.753 82.5531 107.468 83.7647 102.016 83.7647H30.5593V90.5958H102.016C115.912 90.4669 125.573 84.4865 125.37 69.0971C125.928 44.8401 104.324 49.5059 87.6893 49.2997H87.7147Z" fill="url(#paint4_linear_303_614)"/> |
| 16 | <path d="M121.263 126.427C120.198 126.608 119.133 126.814 118.068 127.072C119.539 132.614 118.702 140.218 114.391 142.744C106.505 148.235 82.1114 144.807 72.8306 145.709C76.6088 147.462 80.3871 149.318 83.6582 151.509C84.6217 152.153 85.7375 152.514 86.8785 152.489C103.462 152.205 125.954 157.154 125.422 132.691C125.447 130.062 125.168 127.768 124.661 125.654C123.596 126.04 122.48 126.298 121.288 126.427H121.263Z" fill="url(#paint5_linear_303_614)"/> |
| 17 | <g filter="url(#filter1_d_303_614)"> |
| 18 | <rect x="195.06" y="32.5" width="803.841" height="76.1916" stroke="#2B2D2C" shape-rendering="crispEdges"/> |
| 19 | <path fill-rule="evenodd" clip-rule="evenodd" d="M549.696 108.274V32.9176H582.786C588.268 32.9176 593.264 33.9167 597.661 36.0512C602.078 38.1951 605.594 41.3351 608.098 45.4472C610.637 49.6163 611.813 54.4753 611.813 59.8467C611.813 65.2685 610.6 70.1409 607.941 74.2414C606.304 76.7879 604.248 78.9243 601.812 80.6522L616.857 108.274H587.652L576.157 86.6632V108.274H549.696ZM590.127 104.154H609.921L596.291 79.1301C597.57 78.5163 598.748 77.8075 599.824 77.0036C601.677 75.6192 603.23 73.9531 604.481 72.005C606.622 68.706 607.693 64.6532 607.693 59.8467C607.693 55.0838 606.655 50.9983 604.579 47.59C602.504 44.1817 599.598 41.5709 595.862 39.7576C592.126 37.9442 587.768 37.0375 582.786 37.0375H553.816V104.154H572.037V82.0004H578.343L590.127 104.154ZM572.037 67.843H578.46C580.601 67.843 582.426 67.5808 583.933 67.0565C585.463 66.5103 586.631 65.6473 587.44 64.4675C588.27 63.2877 588.685 61.7474 588.685 59.8467C588.685 57.9241 588.27 56.3619 587.44 55.1603C586.631 53.9368 585.463 53.0411 583.933 52.473C582.426 51.8831 580.601 51.5882 578.46 51.5882H572.037V67.843ZM576.157 55.7081V63.7231H578.46C580.277 63.7231 581.612 63.499 582.563 63.171C583.38 62.8765 583.79 62.5051 584.041 62.1388L584.056 62.1176L584.071 62.0965C584.278 61.8011 584.565 61.1655 584.565 59.8467C584.565 58.4992 584.275 57.8278 584.05 57.5022L584.026 57.467L584.002 57.4314C583.73 57.019 583.305 56.6347 582.499 56.3351L582.465 56.3227L582.432 56.3096C581.53 55.9566 580.244 55.7081 578.46 55.7081H576.157Z" fill="#FAFAFA"/> |
| 20 | <path fill-rule="evenodd" clip-rule="evenodd" d="M475.677 108.274V32.9176H532.288V55.8391H502.138V59.1351H529.797V82.0566H502.138V85.3525H532.157V108.274H475.677ZM498.018 89.4724V77.9367H525.677V63.2549H498.018V51.7193H528.168V37.0375H479.797V104.154H528.037V89.4724H498.018Z" fill="#FAFAFA"/> |
| 21 | <path fill-rule="evenodd" clip-rule="evenodd" d="M406.344 108.274V32.9176H432.805V85.3525H459.94V108.274H406.344ZM428.685 89.4724V37.0375H410.464V104.154H455.82V89.4724H428.685Z" fill="#FAFAFA"/> |
| 22 | <path fill-rule="evenodd" clip-rule="evenodd" d="M389.337 32.9176V108.274H362.876V32.9176H389.337ZM385.217 37.0375H366.996V104.154H385.217V37.0375Z" fill="#FAFAFA"/> |
| 23 | <path fill-rule="evenodd" clip-rule="evenodd" d="M341.181 91.5344L341.177 91.5409C337.907 97.2938 333.433 101.742 327.775 104.771L327.768 104.775L327.762 104.778C322.179 107.742 316.001 109.192 309.313 109.192C302.581 109.192 296.376 107.731 290.786 104.738L290.776 104.733L290.767 104.728C285.138 101.676 280.682 97.2211 277.416 91.4754L277.411 91.4663L277.406 91.4573C274.097 85.5658 272.552 78.5492 272.552 70.5958C272.552 62.6044 274.095 55.5718 277.41 49.6951L277.413 49.69C280.679 43.9181 285.143 39.4637 290.789 36.4517C296.378 33.46 302.582 32 309.313 32C316.004 32 322.185 33.4617 327.769 36.4501C333.435 39.4604 337.911 43.9139 341.18 49.6886C344.519 55.5668 346.074 62.602 346.074 70.5958C346.074 78.5928 344.518 85.6372 341.181 91.5344ZM325.83 40.0853C320.892 37.4417 315.387 36.1199 309.313 36.1199C303.196 36.1199 297.668 37.4417 292.731 40.0853C287.815 42.707 283.904 46.585 280.998 51.7193C278.114 56.8317 276.672 63.1239 276.672 70.5958C276.672 78.0241 278.114 84.3054 280.998 89.4396C283.904 94.552 287.815 98.4409 292.731 101.106C297.668 103.75 303.196 105.072 309.313 105.072C315.387 105.072 320.892 103.761 325.83 101.139C330.768 98.4956 334.689 94.6176 337.595 89.5052C340.501 84.3709 341.954 78.0678 341.954 70.5958C341.954 63.1239 340.501 56.8317 337.595 51.7193C334.689 46.585 330.768 42.707 325.83 40.0853ZM317.756 61.9115L317.749 61.8931C316.943 59.7622 315.851 58.4077 314.59 57.5445L314.569 57.5305L314.549 57.5161C313.319 56.6524 311.655 56.1013 309.313 56.1013C306.973 56.1013 305.289 56.6519 304.028 57.5279L304.02 57.533C302.77 58.3979 301.668 59.7627 300.838 61.9119C300.021 64.0587 299.538 66.9113 299.538 70.5958C299.538 74.2732 300.019 77.1389 300.838 79.3129C301.662 81.4232 302.759 82.7991 304.024 83.6938C305.285 84.5503 306.973 85.0903 309.313 85.0903C311.665 85.0903 313.337 84.545 314.569 83.6941C315.854 82.8 316.95 81.4286 317.753 79.3212L317.756 79.313L317.759 79.3048C318.595 77.1376 319.088 74.2759 319.088 70.5958C319.088 66.9141 318.594 64.0686 317.763 61.9298L317.756 61.9115ZM316.916 87.08C314.862 88.5002 312.328 89.2102 309.313 89.2102C306.298 89.2102 303.753 88.5002 301.677 87.08C299.624 85.6381 298.061 83.5407 296.991 80.7879C295.942 78.0132 295.418 74.6158 295.418 70.5958C295.418 66.5758 295.942 63.1894 296.991 60.4366C298.061 57.6619 299.624 55.5645 301.677 54.1444C303.753 52.7024 306.298 51.9814 309.313 51.9814C312.328 51.9814 314.862 52.7024 316.916 54.1444C318.992 55.5645 320.554 57.6619 321.602 60.4366C322.673 63.1894 323.208 66.5758 323.208 70.5958C323.208 74.6158 322.673 78.0132 321.602 80.7879C320.554 83.5407 318.992 85.6381 316.916 87.08Z" fill="#FAFAFA"/> |
| 24 | <path fill-rule="evenodd" clip-rule="evenodd" d="M194.56 108.274V32.9176H227.781C233.232 32.9176 238.075 33.6472 242.173 35.2702C246.242 36.8731 249.59 39.253 251.916 42.5486C254.255 45.8309 255.365 49.6469 255.365 53.8167C255.365 56.9112 254.675 59.8338 253.244 62.5031C252.104 64.6623 250.581 66.5265 248.711 68.0855C251.447 69.7483 253.658 71.9966 255.309 74.7798C257.146 77.8531 257.987 81.3596 257.987 85.1465C257.987 89.576 256.814 93.6497 254.409 97.2336C252.037 100.802 248.706 103.516 244.595 105.431C240.412 107.379 235.595 108.274 230.271 108.274H194.56ZM244.445 66.1032C244.662 65.967 244.875 65.8259 245.084 65.68C247.029 64.3036 248.536 62.5995 249.607 60.5676C250.699 58.5358 251.245 56.2855 251.245 53.8167C251.245 50.4084 250.35 47.448 248.558 44.9355C246.788 42.423 244.156 40.4785 240.66 39.1021C237.186 37.7257 232.893 37.0375 227.781 37.0375H198.679V104.154H230.271C235.143 104.154 239.338 103.335 242.856 101.696C246.373 100.058 249.082 97.8073 250.983 94.9453C252.906 92.0832 253.867 88.8169 253.867 85.1465C253.867 81.9786 253.168 79.2257 251.77 76.888C250.371 74.5284 248.493 72.6823 246.133 71.3496C245.682 71.0907 245.222 70.8546 244.752 70.6413C242.8 69.7553 240.683 69.2613 238.401 69.1594C238.357 69.1574 238.312 69.1556 238.268 69.1539V68.4984C238.276 68.4967 238.284 68.4951 238.293 68.4934C240.602 68.0248 242.653 67.2281 244.445 66.1032ZM228.809 80.0833L228.797 80.0783C228.133 79.7907 227.138 79.5659 225.683 79.5659H221.02V85.4836H225.421C228.065 85.4836 229.319 84.9764 229.832 84.6027C230.143 84.3635 230.477 83.9794 230.477 82.7869C230.477 81.8557 230.271 81.3515 230.077 81.0582C229.849 80.7117 229.491 80.3743 228.82 80.0881L228.809 80.0833ZM232.303 87.8993C230.774 89.0354 228.48 89.6035 225.421 89.6035H216.901V75.446H225.683C227.54 75.446 229.124 75.7301 230.435 76.2981C231.768 76.8662 232.795 77.6964 233.516 78.7888C234.237 79.8812 234.597 81.2139 234.597 82.7869C234.597 85.0373 233.833 86.7414 232.303 87.8993ZM228.114 58.8396L228.119 58.8327C228.228 58.6826 228.38 58.3975 228.38 57.7493C228.38 56.7949 228.117 56.5269 227.831 56.3166L227.815 56.305L227.799 56.2933C227.174 55.8244 226.219 55.4459 224.635 55.4459H221.02V60.0527H224.372C225.538 60.0527 226.399 59.869 227.035 59.6078C227.65 59.3551 227.943 59.0772 228.109 58.8465L228.114 58.8396ZM224.372 64.1726C225.967 64.1726 227.377 63.9213 228.6 63.4188C229.824 62.9163 230.774 62.1953 231.451 61.2559C232.15 60.2946 232.5 59.1257 232.5 57.7493C232.5 55.6737 231.757 54.0898 230.271 52.9974C228.786 51.8831 226.907 51.326 224.635 51.326H216.901V64.1726H224.372Z" fill="#FAFAFA"/> |
| 25 | <path d="M979.577 55.6468C979.388 53.2869 978.503 51.4461 976.922 50.1245C975.365 48.8029 972.993 48.1421 969.807 48.1421C967.777 48.1421 966.114 48.3899 964.816 48.8855C963.541 49.3575 962.597 50.0065 961.984 50.8325C961.37 51.6585 961.051 52.6025 961.028 53.6645C960.981 54.5377 961.134 55.3282 961.488 56.0362C961.866 56.7206 962.456 57.346 963.258 57.9124C964.06 58.4552 965.087 58.9508 966.338 59.3992C967.589 59.8476 969.075 60.2488 970.798 60.6028L976.745 61.8771C980.757 62.7267 984.191 63.8477 987.046 65.2401C989.902 66.6325 992.238 68.2726 994.056 70.1606C995.873 72.025 997.206 74.1253 998.056 76.4617C998.929 78.7981 999.377 81.3468 999.401 84.108C999.377 88.8751 998.185 92.9107 995.826 96.2146C993.466 99.5185 990.091 102.032 985.701 103.755C981.335 105.477 976.084 106.339 969.948 106.339C963.647 106.339 958.149 105.407 953.452 103.542C948.78 101.678 945.145 98.8106 942.549 94.9402C939.977 91.0463 938.679 86.0668 938.655 80.0017H957.346C957.464 82.22 958.019 84.0844 959.01 85.5948C960.001 87.1051 961.394 88.2497 963.187 89.0285C965.004 89.8073 967.164 90.1967 969.665 90.1967C971.766 90.1967 973.524 89.9371 974.94 89.4179C976.356 88.8987 977.43 88.1789 978.161 87.2585C978.893 86.3382 979.27 85.288 979.294 84.108C979.27 82.9988 978.905 82.0312 978.197 81.2052C977.512 80.3557 976.379 79.6005 974.798 78.9397C973.217 78.2553 971.081 77.6181 968.391 77.0281L961.169 75.4705C954.75 74.0781 949.688 71.7536 945.983 68.4968C942.302 65.2165 940.473 60.7444 940.496 55.0804C940.473 50.4785 941.7 46.4548 944.178 43.0092C946.679 39.5401 950.137 36.8379 954.55 34.9028C958.986 32.9676 964.072 32 969.807 32C975.66 32 980.722 32.9794 984.993 34.9382C989.265 36.8969 992.557 39.6581 994.87 43.2216C997.206 46.7616 998.386 50.9033 998.41 55.6468H979.577Z" fill="#FAFAFA"/> |
| 26 | <path d="M870.211 105.489V32.9912H889.893V62.3019H890.884L912.69 32.9912H935.629L911.132 65.2755L936.195 105.489H912.69L896.406 78.3025L889.893 86.7983V105.489H870.211Z" fill="#FAFAFA"/> |
| 27 | <path d="M803.624 105.489V32.9912H834.917C840.298 32.9912 845.006 33.9706 849.042 35.9293C853.077 37.8881 856.216 40.7083 858.458 44.3898C860.7 48.0714 861.821 52.4845 861.821 57.6292C861.821 62.8211 860.664 67.1988 858.352 70.7624C856.063 74.3259 852.841 77.0163 848.688 78.8335C844.558 80.6506 839.732 81.5592 834.209 81.5592H815.518V66.2667H830.245C832.557 66.2667 834.528 65.9835 836.156 65.4171C837.808 64.8271 839.071 63.8949 839.944 62.6205C840.841 61.3461 841.289 59.6824 841.289 57.6292C841.289 55.5524 840.841 53.8651 839.944 52.5671C839.071 51.2455 837.808 50.2779 836.156 49.6643C834.528 49.0271 832.557 48.7085 830.245 48.7085H823.306V105.489H803.624ZM846.103 72.2138L864.228 105.489H842.847L825.147 72.2138H846.103Z" fill="#FAFAFA"/> |
| 28 | <path d="M794.518 69.2402C794.518 77.3113 792.948 84.1198 789.81 89.6657C786.671 95.188 782.435 99.377 777.101 102.233C771.768 105.064 765.82 106.48 759.26 106.48C752.652 106.48 746.681 105.053 741.348 102.197C736.038 99.318 731.813 95.1172 728.675 89.5949C725.56 84.049 724.002 77.2641 724.002 69.2402C724.002 61.1691 725.56 54.3725 728.675 48.8501C731.813 43.3042 736.038 39.1153 741.348 36.2833C746.681 33.4278 752.652 32 759.26 32C765.82 32 771.768 33.4278 777.101 36.2833C782.435 39.1153 786.671 43.3042 789.81 48.8501C792.948 54.3725 794.518 61.1691 794.518 69.2402ZM774.269 69.2402C774.269 64.8979 773.691 61.2399 772.535 58.2664C771.402 55.2692 769.714 53.0037 767.472 51.4697C765.254 49.9121 762.517 49.1333 759.26 49.1333C756.003 49.1333 753.254 49.9121 751.012 51.4697C748.793 53.0037 747.106 55.2692 745.95 58.2664C744.817 61.2399 744.25 64.8979 744.25 69.2402C744.25 73.5826 744.817 77.2523 745.95 80.2495C747.106 83.223 748.793 85.4886 751.012 87.0461C753.254 88.5801 756.003 89.3471 759.26 89.3471C762.517 89.3471 765.254 88.5801 767.472 87.0461C769.714 85.4886 771.402 83.223 772.535 80.2495C773.691 77.2523 774.269 73.5826 774.269 69.2402Z" fill="#FAFAFA"/> |
| 29 | <path d="M638.238 105.489L616.857 32.9912H638.804L648.716 77.5945H649.283L661.035 32.9912H678.31L690.063 77.7361H690.629L700.541 32.9912H722.489L701.107 105.489H682.275L669.956 64.9923H669.389L657.07 105.489H638.238Z" fill="#FAFAFA"/> |
| 30 | </g> |
| 31 | <path d="M649.525 133.05H642.505V164.911H638.725V133.05H631.704V129.81H649.525V133.05Z" fill="#FAFAFA"/> |
| 32 | <path d="M622.242 155.191H626.022V158.161C626.022 160.501 625.302 162.355 623.862 163.723C622.422 165.055 620.19 165.721 617.165 165.721C614.141 165.721 611.891 165.055 610.415 163.723C608.939 162.355 608.201 160.501 608.201 158.161V136.56C608.201 134.22 608.921 132.384 610.361 131.052C611.801 129.684 614.033 129 617.057 129C620.082 129 622.332 129.684 623.808 131.052C625.284 132.384 626.022 134.22 626.022 136.56V141.69H622.242V136.56C622.242 135.192 621.81 134.13 620.946 133.374C620.117 132.618 618.821 132.24 617.057 132.24C615.329 132.24 614.051 132.618 613.223 133.374C612.395 134.13 611.981 135.192 611.981 136.56V158.161C611.981 159.493 612.395 160.555 613.223 161.347C614.087 162.103 615.401 162.481 617.165 162.481C618.893 162.481 620.171 162.103 621 161.347C621.828 160.555 622.242 159.493 622.242 158.161V155.191Z" fill="#FAFAFA"/> |
| 33 | <path d="M594.456 164.911V129.81H598.236V164.911H594.456Z" fill="#FAFAFA"/> |
| 34 | <path d="M551.898 164.911V129.81H566.749V133.05H555.678V145.471H564.589V148.711H555.678V164.911H551.898ZM574.309 129.81H578.089V161.671H587.971V164.911H574.309V129.81Z" fill="#FAFAFA"/> |
| 35 | <path d="M521.839 164.911V129.81H525.457L537.337 156.811V129.81H541.117V164.911H537.499L525.619 137.91V164.911H521.839Z" fill="#FAFAFA"/> |
| 36 | <path d="M481.068 155.191H484.848V158.161C484.848 160.501 484.128 162.355 482.688 163.723C481.248 165.055 479.015 165.721 475.991 165.721C472.967 165.721 470.717 165.055 469.241 163.723C467.765 162.355 467.027 160.501 467.027 158.161V136.56C467.027 134.22 467.747 132.384 469.187 131.052C470.627 129.684 472.859 129 475.883 129C478.907 129 481.158 129.684 482.634 131.052C484.11 132.384 484.848 134.22 484.848 136.56V141.69H481.068V136.56C481.068 135.192 480.635 134.13 479.771 133.374C478.943 132.618 477.647 132.24 475.883 132.24C474.155 132.24 472.877 132.618 472.049 133.374C471.221 134.13 470.807 135.192 470.807 136.56V158.161C470.807 159.493 471.221 160.555 472.049 161.347C472.913 162.103 474.227 162.481 475.991 162.481C477.719 162.481 478.997 162.103 479.825 161.347C480.653 160.555 481.068 159.493 481.068 158.161V155.191Z" fill="#FAFAFA"/> |
| 37 | <path d="M435.94 164.911V152.329L427.03 129.81H431.026L437.83 147.901L444.635 129.81H448.631L439.72 152.383V164.911H435.94Z" fill="#FAFAFA"/> |
| 38 | <path d="M411.97 129.81C414.85 129.81 416.992 130.494 418.396 131.862C419.836 133.194 420.556 135.03 420.556 137.37V140.61C420.556 143.67 419.278 145.633 416.722 146.497C419.638 147.289 421.096 149.287 421.096 152.491V157.351C421.096 159.691 420.394 161.545 418.99 162.913C417.586 164.245 415.426 164.911 412.51 164.911H403.816V129.81H411.97ZM416.776 137.37C416.776 136.038 416.362 134.994 415.534 134.238C414.742 133.446 413.554 133.05 411.97 133.05H407.596V145.201H411.97C413.59 145.201 414.796 144.768 415.588 143.904C416.38 143.04 416.776 141.942 416.776 140.61V137.37ZM417.316 152.491C417.316 151.159 416.902 150.061 416.074 149.197C415.282 148.333 414.094 147.901 412.51 147.901H407.596V161.671H412.51C414.13 161.671 415.336 161.293 416.128 160.537C416.92 159.745 417.316 158.683 417.316 157.351V152.491Z" fill="#FAFAFA"/> |
| 39 | <path d="M364.106 164.911V129.81H372.8C375.68 129.81 377.822 130.494 379.226 131.862C380.666 133.194 381.386 135.03 381.386 137.37V157.351C381.386 159.691 380.684 161.545 379.28 162.913C377.876 164.245 375.716 164.911 372.8 164.911H364.106ZM377.606 137.37C377.606 136.038 377.192 134.994 376.364 134.238C375.572 133.446 374.384 133.05 372.8 133.05H367.886V161.671H372.8C374.42 161.671 375.626 161.293 376.418 160.537C377.21 159.745 377.606 158.683 377.606 157.351V137.37Z" fill="#FAFAFA"/> |
| 40 | <path d="M350.335 155.191H354.115V158.161C354.115 160.501 353.395 162.355 351.955 163.723C350.515 165.055 348.283 165.721 345.259 165.721C342.235 165.721 339.985 165.055 338.509 163.723C337.033 162.355 336.295 160.501 336.295 158.161V136.56C336.295 134.22 337.015 132.384 338.455 131.052C339.895 129.684 342.127 129 345.151 129C348.175 129 350.425 129.684 351.901 131.052C353.377 132.384 354.115 134.22 354.115 136.56V148.441H340.075V158.161C340.075 159.493 340.489 160.555 341.317 161.347C342.181 162.103 343.495 162.481 345.259 162.481C346.987 162.481 348.265 162.103 349.093 161.347C349.921 160.555 350.335 159.493 350.335 158.161V155.191ZM340.075 145.2H350.335V136.56C350.335 135.192 349.903 134.13 349.039 133.374C348.211 132.618 346.915 132.24 345.151 132.24C343.423 132.24 342.145 132.618 341.317 133.374C340.489 134.13 340.075 135.192 340.075 136.56V145.2Z" fill="#FAFAFA"/> |
| 41 | <path d="M315.483 164.911V129.81H317.103L318.669 133.806C319.173 132.438 319.857 131.304 320.721 130.404C321.585 129.468 322.737 129 324.177 129C326.265 129 327.849 129.594 328.929 130.782C330.045 131.934 330.603 133.68 330.603 136.02V139.53H326.985V135.804C326.985 134.76 326.751 133.914 326.283 133.266C325.815 132.582 325.059 132.24 324.015 132.24C323.331 132.24 322.611 132.51 321.855 133.05C321.135 133.59 320.523 134.454 320.019 135.642C319.515 136.794 319.263 138.324 319.263 140.232V164.911H315.483Z" fill="#FAFAFA"/> |
| 42 | <path d="M301.713 155.191H305.493V158.161C305.493 160.501 304.773 162.355 303.333 163.723C301.893 165.055 299.661 165.721 296.636 165.721C293.612 165.721 291.362 165.055 289.886 163.723C288.41 162.355 287.672 160.501 287.672 158.161V136.56C287.672 134.22 288.392 132.384 289.832 131.052C291.272 129.684 293.504 129 296.528 129C299.553 129 301.803 129.684 303.279 131.052C304.755 132.384 305.493 134.22 305.493 136.56V148.441H291.452V158.161C291.452 159.493 291.866 160.555 292.694 161.347C293.558 162.103 294.872 162.481 296.636 162.481C298.365 162.481 299.643 162.103 300.471 161.347C301.299 160.555 301.713 159.493 301.713 158.161V155.191ZM291.452 145.2H301.713V136.56C301.713 135.192 301.281 134.13 300.417 133.374C299.589 132.618 298.293 132.24 296.528 132.24C294.8 132.24 293.522 132.618 292.694 133.374C291.866 134.13 291.452 135.192 291.452 136.56V145.2Z" fill="#FAFAFA"/> |
| 43 | <path d="M260.684 129.81H264.464V158.161C264.464 159.493 264.878 160.555 265.706 161.347C266.534 162.103 267.776 162.481 269.432 162.481C271.124 162.481 272.384 162.103 273.212 161.347C274.04 160.555 274.454 159.493 274.454 158.161V129.81H278.235V158.161C278.235 160.501 277.479 162.355 275.966 163.723C274.49 165.055 272.312 165.721 269.432 165.721C267.776 165.721 266.39 165.433 265.274 164.857C264.158 164.281 263.258 163.525 262.574 162.589C261.926 163.561 261.026 164.335 259.874 164.911C258.722 165.451 257.336 165.721 255.716 165.721C252.836 165.721 250.64 165.055 249.128 163.723C247.652 162.355 246.914 160.501 246.914 158.161V129.81H250.694V158.161C250.694 159.493 251.108 160.555 251.936 161.347C252.8 162.103 254.06 162.481 255.716 162.481C257.408 162.481 258.65 162.103 259.442 161.347C260.27 160.555 260.684 159.493 260.684 158.161V129.81Z" fill="#FAFAFA"/> |
| 44 | <path d="M237.476 158.161C237.476 160.501 236.72 162.355 235.208 163.723C233.732 165.055 231.446 165.721 228.35 165.721C225.254 165.721 222.932 165.055 221.383 163.723C219.871 162.355 219.115 160.501 219.115 158.161V136.56C219.115 134.22 219.853 132.384 221.329 131.052C222.842 129.684 225.146 129 228.242 129C231.338 129 233.642 129.684 235.154 131.052C236.702 132.384 237.476 134.22 237.476 136.56V158.161ZM233.696 136.56C233.696 135.192 233.246 134.13 232.346 133.374C231.446 132.618 230.078 132.24 228.242 132.24C226.442 132.24 225.092 132.618 224.192 133.374C223.328 134.13 222.896 135.192 222.896 136.56V158.161C222.896 159.493 223.346 160.555 224.246 161.347C225.146 162.103 226.514 162.481 228.35 162.481C230.15 162.481 231.482 162.103 232.346 161.347C233.246 160.555 233.696 159.493 233.696 158.161V136.56Z" fill="#FAFAFA"/> |
| 45 | <path d="M194.56 164.911V129.81H202.714C205.594 129.81 207.736 130.494 209.14 131.862C210.58 133.194 211.3 135.03 211.3 137.37V146.551C211.3 148.891 210.598 150.745 209.194 152.113C207.79 153.445 205.63 154.111 202.714 154.111H198.34V164.911H194.56ZM207.52 137.37C207.52 136.038 207.106 134.994 206.278 134.238C205.486 133.446 204.298 133.05 202.714 133.05H198.34V150.871H202.714C204.334 150.871 205.54 150.493 206.332 149.737C207.124 148.945 207.52 147.883 207.52 146.551V137.37Z" fill="#FAFAFA"/> |
| 46 | </g> |
| 47 | <defs> |
| 48 | <filter id="filter0_d_303_614" x="0" y="0" width="1033" height="213" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> |
| 49 | <feFlood flood-opacity="0" result="BackgroundImageFix"/> |
| 50 | <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> |
| 51 | <feOffset dy="4"/> |
| 52 | <feGaussianBlur stdDeviation="2"/> |
| 53 | <feComposite in2="hardAlpha" operator="out"/> |
| 54 | <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> |
| 55 | <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/> |
| 56 | <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/> |
| 57 | </filter> |
| 58 | <filter id="filter1_d_303_614" x="190.56" y="32" width="812.841" height="85.1917" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> |
| 59 | <feFlood flood-opacity="0" result="BackgroundImageFix"/> |
| 60 | <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> |
| 61 | <feOffset dy="4"/> |
| 62 | <feGaussianBlur stdDeviation="2"/> |
| 63 | <feComposite in2="hardAlpha" operator="out"/> |
| 64 | <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> |
| 65 | <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/> |
| 66 | <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/> |
| 67 | </filter> |
| 68 | <linearGradient id="paint0_linear_303_614" x1="48.4373" y1="83.8162" x2="111.888" y2="65.7367" gradientUnits="userSpaceOnUse"> |
| 69 | <stop stop-color="#DD4652"/> |
| 70 | <stop offset="0.15" stop-color="#DD4854"/> |
| 71 | <stop offset="0.23" stop-color="#DE4F5B"/> |
| 72 | <stop offset="0.29" stop-color="#E15C67"/> |
| 73 | <stop offset="0.34" stop-color="#E46F78"/> |
| 74 | <stop offset="0.38" stop-color="#E9878F"/> |
| 75 | <stop offset="0.42" stop-color="#EEA5AB"/> |
| 76 | <stop offset="0.46" stop-color="#F5C8CC"/> |
| 77 | <stop offset="0.49" stop-color="#FCF0F1"/> |
| 78 | <stop offset="0.5" stop-color="#FEFDFD"/> |
| 79 | <stop offset="0.52" stop-color="#F7D4D8"/> |
| 80 | <stop offset="0.55" stop-color="#F1B0B7"/> |
| 81 | <stop offset="0.58" stop-color="#EB8F9A"/> |
| 82 | <stop offset="0.61" stop-color="#E67481"/> |
| 83 | <stop offset="0.64" stop-color="#E25E6D"/> |
| 84 | <stop offset="0.68" stop-color="#DF4D5E"/> |
| 85 | <stop offset="0.73" stop-color="#DD4153"/> |
| 86 | <stop offset="0.8" stop-color="#DC3A4D"/> |
| 87 | <stop offset="1" stop-color="#DC394C"/> |
| 88 | </linearGradient> |
| 89 | <linearGradient id="paint1_linear_303_614" x1="107.569" y1="12.2053" x2="11.6611" y2="39.2106" gradientUnits="userSpaceOnUse"> |
| 90 | <stop stop-color="#8B3138" stop-opacity="0"/> |
| 91 | <stop offset="0.65" stop-color="#DC394C"/> |
| 92 | </linearGradient> |
| 93 | <linearGradient id="paint2_linear_303_614" x1="107.569" y1="189.634" x2="11.6864" y2="162.629" gradientUnits="userSpaceOnUse"> |
| 94 | <stop stop-color="#8B3138" stop-opacity="0"/> |
| 95 | <stop offset="0.65" stop-color="#DC394C"/> |
| 96 | </linearGradient> |
| 97 | <linearGradient id="paint3_linear_303_614" x1="30.5598" y1="131.944" x2="125.396" y2="131.944" gradientUnits="userSpaceOnUse"> |
| 98 | <stop stop-color="#8B3138" stop-opacity="0"/> |
| 99 | <stop offset="0.65" stop-color="#DC394C"/> |
| 100 | </linearGradient> |
| 101 | <linearGradient id="paint4_linear_303_614" x1="30.5847" y1="69.8189" x2="125.396" y2="69.8189" gradientUnits="userSpaceOnUse"> |
| 102 | <stop stop-color="#8B3138" stop-opacity="0"/> |
| 103 | <stop offset="0.65" stop-color="#DC394C"/> |
| 104 | </linearGradient> |
| 105 | <linearGradient id="paint5_linear_303_614" x1="-48.1491" y1="140.528" x2="123.546" y2="139.131" gradientUnits="userSpaceOnUse"> |
| 106 | <stop stop-color="#6A1921"/> |
| 107 | <stop offset="1" stop-color="#DC394C"/> |
| 108 | </linearGradient> |
| 109 | </defs> |
| 110 | </svg> |
| 111 |
| --- static/img/fossilrepo-logo-dark.svg | |
| +++ static/img/fossilrepo-logo-dark.svg | |
| @@ -1,110 +1,13 @@ | |
| 1 | <svg width="220" height="36" viewBox="0 0 220 36" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 2 | <!-- Ammonite spiral icon --> |
| 3 | <g transform="translate(4, 2)"> |
| 4 | <circle cx="16" cy="16" r="14" stroke="#DC394C" stroke-width="2.5" fill="none"/> |
| 5 | <path d="M16 2C8.268 2 2 8.268 2 16" stroke="#DC394C" stroke-width="2.5" stroke-linecap="round" fill="none"/> |
| 6 | <path d="M16 7C11.029 7 7 11.029 7 16c0 4.971 4.029 9 9 9" stroke="#e8677a" stroke-width="2" stroke-linecap="round" fill="none"/> |
| 7 | <path d="M16 12c-2.209 0-4 1.791-4 4s1.791 4 4 4" stroke="#DC394C" stroke-width="1.5" stroke-linecap="round" fill="none"/> |
| 8 | <circle cx="16" cy="16" r="1.5" fill="#DC394C"/> |
| 9 | </g> |
| 10 | <!-- Wordmark --> |
| 11 | <text x="42" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#e5e5e5" letter-spacing="-0.5">fossil</text> |
| 12 | <text x="102" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#DC394C" letter-spacing="-0.5">repo</text> |
| 13 | </svg> |
| 14 |
| --- templates/admin/base_site.html | ||
| +++ templates/admin/base_site.html | ||
| @@ -33,11 +33,11 @@ | ||
| 33 | 33 | |
| 34 | 34 | {% block branding %} |
| 35 | 35 | <h1 id="site-name"> |
| 36 | 36 | <div class="logo"> |
| 37 | 37 | <a role="listitem" class="item" href="/"> |
| 38 | - <img src="{% static 'admin/img/logo-dark.svg' %}" alt="Fossilrepo" height="40"> | |
| 38 | + <img src="{% static 'admin/img/logo-dark.png' %}" alt="Fossilrepo" height="40"> | |
| 39 | 39 | </a> |
| 40 | 40 | </div> |
| 41 | 41 | </h1> |
| 42 | 42 | {% endblock %} |
| 43 | 43 | |
| 44 | 44 |
| --- templates/admin/base_site.html | |
| +++ templates/admin/base_site.html | |
| @@ -33,11 +33,11 @@ | |
| 33 | |
| 34 | {% block branding %} |
| 35 | <h1 id="site-name"> |
| 36 | <div class="logo"> |
| 37 | <a role="listitem" class="item" href="/"> |
| 38 | <img src="{% static 'admin/img/logo-dark.svg' %}" alt="Fossilrepo" height="40"> |
| 39 | </a> |
| 40 | </div> |
| 41 | </h1> |
| 42 | {% endblock %} |
| 43 | |
| 44 |
| --- templates/admin/base_site.html | |
| +++ templates/admin/base_site.html | |
| @@ -33,11 +33,11 @@ | |
| 33 | |
| 34 | {% block branding %} |
| 35 | <h1 id="site-name"> |
| 36 | <div class="logo"> |
| 37 | <a role="listitem" class="item" href="/"> |
| 38 | <img src="{% static 'admin/img/logo-dark.png' %}" alt="Fossilrepo" height="40"> |
| 39 | </a> |
| 40 | </div> |
| 41 | </h1> |
| 42 | {% endblock %} |
| 43 | |
| 44 |
| --- templates/auth1/login.html | ||
| +++ templates/auth1/login.html | ||
| @@ -4,11 +4,11 @@ | ||
| 4 | 4 | |
| 5 | 5 | {% block content %} |
| 6 | 6 | <div class="flex min-h-[80vh] items-center justify-center"> |
| 7 | 7 | <div class="w-full max-w-sm space-y-8"> |
| 8 | 8 | <div class="flex flex-col items-center"> |
| 9 | - <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-12 w-auto mb-6"> | |
| 9 | + <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6"> | |
| 10 | 10 | <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2> |
| 11 | 11 | <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p> |
| 12 | 12 | </div> |
| 13 | 13 | |
| 14 | 14 | {% if form.errors %} |
| 15 | 15 |
| --- templates/auth1/login.html | |
| +++ templates/auth1/login.html | |
| @@ -4,11 +4,11 @@ | |
| 4 | |
| 5 | {% block content %} |
| 6 | <div class="flex min-h-[80vh] items-center justify-center"> |
| 7 | <div class="w-full max-w-sm space-y-8"> |
| 8 | <div class="flex flex-col items-center"> |
| 9 | <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-12 w-auto mb-6"> |
| 10 | <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2> |
| 11 | <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p> |
| 12 | </div> |
| 13 | |
| 14 | {% if form.errors %} |
| 15 |
| --- templates/auth1/login.html | |
| +++ templates/auth1/login.html | |
| @@ -4,11 +4,11 @@ | |
| 4 | |
| 5 | {% block content %} |
| 6 | <div class="flex min-h-[80vh] items-center justify-center"> |
| 7 | <div class="w-full max-w-sm space-y-8"> |
| 8 | <div class="flex flex-col items-center"> |
| 9 | <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6"> |
| 10 | <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2> |
| 11 | <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p> |
| 12 | </div> |
| 13 | |
| 14 | {% if form.errors %} |
| 15 |
| --- templates/base.html | ||
| +++ templates/base.html | ||
| @@ -1,15 +1,28 @@ | ||
| 1 | 1 | <!DOCTYPE html> |
| 2 | -<html lang="en" class="h-full bg-gray-950"> | |
| 2 | +<html lang="en" class="h-full dark"> | |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | 6 | <meta name="csrf-token" content="{{ csrf_token }}"> |
| 7 | 7 | <title>{% block title %}Fossilrepo{% endblock %}</title> |
| 8 | - <script src="https://cdn.tailwindcss.com"></script> | |
| 8 | + <script> | |
| 9 | + // Apply theme before anything renders to prevent flash | |
| 10 | + (function() { | |
| 11 | + var theme = localStorage.getItem('theme'); | |
| 12 | + if (theme === 'light') { | |
| 13 | + document.documentElement.classList.remove('dark'); | |
| 14 | + } else { | |
| 15 | + document.documentElement.classList.add('dark'); | |
| 16 | + } | |
| 17 | + })(); | |
| 18 | + </script> | |
| 19 | + <script src="https://cdn.tailwindcss.com?plugins=typography"></script> | |
| 20 | + {% block extra_head %}{% endblock %} | |
| 9 | 21 | <script> |
| 10 | 22 | tailwind.config = { |
| 23 | + darkMode: 'class', | |
| 11 | 24 | theme: { |
| 12 | 25 | extend: { |
| 13 | 26 | colors: { |
| 14 | 27 | brand: { |
| 15 | 28 | DEFAULT: '#DC394C', |
| @@ -25,15 +38,76 @@ | ||
| 25 | 38 | <style type="text/tailwindcss"> |
| 26 | 39 | @layer base { |
| 27 | 40 | input[type="text"], input[type="number"], input[type="email"], |
| 28 | 41 | input[type="password"], input[type="search"], input[type="url"], |
| 29 | 42 | textarea, select { |
| 30 | - @apply bg-gray-800 border-gray-700 text-gray-100 rounded-md shadow-sm | |
| 43 | + @apply bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 | |
| 44 | + text-gray-900 dark:text-gray-100 rounded-md shadow-sm | |
| 31 | 45 | focus:border-brand focus:ring-brand sm:text-sm; |
| 32 | 46 | } |
| 33 | 47 | } |
| 34 | 48 | </style> |
| 49 | + <style> | |
| 50 | + /* | |
| 51 | + * Light mode — matches Django admin dark_theme.css palette | |
| 52 | + * Brand: #DC394C red, #8B3138 crimson, #2B2D2C charcoal | |
| 53 | + * Nav bar stays dark. Only main content area switches. | |
| 54 | + */ | |
| 55 | + html:not(.dark) body { background-color: #f8f8f8; color: #1a1a1a; } | |
| 56 | + /* Surfaces */ | |
| 57 | + html:not(.dark) main .bg-gray-950 { background-color: #f8f8f8 !important; } | |
| 58 | + html:not(.dark) main .bg-gray-900 { background-color: #eeeeee !important; } | |
| 59 | + html:not(.dark) main .bg-gray-800 { background-color: #ffffff !important; } | |
| 60 | + html:not(.dark) main .bg-gray-700 { background-color: #eeeeee !important; } | |
| 61 | + html:not(.dark) main .bg-gray-800\/50, | |
| 62 | + html:not(.dark) main .hover\:bg-gray-800\/50:hover { background-color: #eeeeee !important; } | |
| 63 | + html:not(.dark) main .hover\:bg-gray-800:hover { background-color: #eeeeee !important; } | |
| 64 | + html:not(.dark) main .hover\:bg-gray-700:hover { background-color: #e0e0e0 !important; } | |
| 65 | + html:not(.dark) main .hover\:bg-gray-700\/50:hover { background-color: #eeeeee !important; } | |
| 66 | + html:not(.dark) main .hover\:bg-gray-600:hover { background-color: #e0e0e0 !important; } | |
| 67 | + /* Text */ | |
| 68 | + html:not(.dark) main .text-gray-100 { color: #1a1a1a !important; } | |
| 69 | + html:not(.dark) main .text-gray-200 { color: #1a1a1a !important; } | |
| 70 | + html:not(.dark) main .text-gray-300 { color: #666666 !important; } | |
| 71 | + html:not(.dark) main .text-gray-400 { color: #666666 !important; } | |
| 72 | + html:not(.dark) main .text-gray-500 { color: #a8aaa9 !important; } | |
| 73 | + html:not(.dark) main .hover\:text-white:hover { color: #000000 !important; } | |
| 74 | + html:not(.dark) main .hover\:text-gray-200:hover { color: #1a1a1a !important; } | |
| 75 | + /* Borders — matches --hairline-color / --border-color */ | |
| 76 | + html:not(.dark) main .border-gray-700 { border-color: #e0e0e0 !important; } | |
| 77 | + html:not(.dark) main .border-gray-600 { border-color: #e0e0e0 !important; } | |
| 78 | + html:not(.dark) main .divide-gray-700 > :not([hidden]) ~ :not([hidden]) { border-color: #e0e0e0 !important; } | |
| 79 | + html:not(.dark) main .ring-gray-700 { --tw-ring-color: #e0e0e0 !important; } | |
| 80 | + html:not(.dark) main .ring-gray-600 { --tw-ring-color: #e0e0e0 !important; } | |
| 81 | + html:not(.dark) main .shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05) !important; } | |
| 82 | + /* Status badges — matches admin message colors */ | |
| 83 | + html:not(.dark) main .bg-green-900\/50 { background-color: #d4edda !important; } | |
| 84 | + html:not(.dark) main .text-green-300 { color: #155724 !important; } | |
| 85 | + html:not(.dark) main .border-green-700 { border-color: #c3e6cb !important; } | |
| 86 | + html:not(.dark) main .bg-yellow-900\/50 { background-color: #fff3cd !important; } | |
| 87 | + html:not(.dark) main .text-yellow-300 { color: #856404 !important; } | |
| 88 | + html:not(.dark) main .text-yellow-400 { color: #856404 !important; } | |
| 89 | + html:not(.dark) main .bg-purple-900\/50 { background-color: #f3e8ff !important; } | |
| 90 | + html:not(.dark) main .text-purple-300 { color: #9333ea !important; } | |
| 91 | + html:not(.dark) main .bg-blue-900\/50 { background-color: #dbeafe !important; } | |
| 92 | + html:not(.dark) main .text-blue-300 { color: #2563eb !important; } | |
| 93 | + html:not(.dark) main .bg-red-900\/50 { background-color: #f8d7da !important; } | |
| 94 | + html:not(.dark) main .text-red-300 { color: #721c24 !important; } | |
| 95 | + html:not(.dark) main .border-red-700 { border-color: #f5c6cb !important; } | |
| 96 | + html:not(.dark) main .text-red-400 { color: #c0392b !important; } | |
| 97 | + html:not(.dark) main .hover\:text-red-300:hover { color: #721c24 !important; } | |
| 98 | + /* Mono text */ | |
| 99 | + html:not(.dark) main .font-mono { color: #666666 !important; } | |
| 100 | + /* Prose — matches admin link/text colors */ | |
| 101 | + html:not(.dark) main .prose-invert { --tw-prose-body: #1a1a1a; --tw-prose-headings: #000000; --tw-prose-links: #DC394C; --tw-prose-bold: #000000; --tw-prose-code: #1a1a1a; --tw-prose-th-borders: #e0e0e0; --tw-prose-td-borders: #e0e0e0; } | |
| 102 | + /* Brand links — matches admin --link-fg */ | |
| 103 | + html:not(.dark) main .text-brand-light { color: #DC394C !important; } | |
| 104 | + html:not(.dark) main .hover\:text-brand:hover { color: #8B3138 !important; } | |
| 105 | + html:not(.dark) main .hover\:border-brand:hover { border-color: #DC394C !important; } | |
| 106 | + /* Selected/hover rows — matches admin --selected-bg */ | |
| 107 | + html:not(.dark) main .group:hover { border-color: #DC394C !important; } | |
| 108 | + </style> | |
| 35 | 109 | <script src="https://unpkg.com/[email protected]"></script> |
| 36 | 110 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 37 | 111 | <script> |
| 38 | 112 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 39 | 113 | var token = document.querySelector('meta[name="csrf-token"]'); |
| @@ -40,32 +114,38 @@ | ||
| 40 | 114 | if (token) { event.detail.headers['X-CSRFToken'] = token.content; } |
| 41 | 115 | }); |
| 42 | 116 | </script> |
| 43 | 117 | </head> |
| 44 | 118 | <body class="h-full bg-gray-950 text-gray-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> |
| 45 | - <div class="min-h-full"> | |
| 119 | + <div class="min-h-full flex flex-col"> | |
| 46 | 120 | {% if user.is_authenticated %} |
| 47 | 121 | {% include "includes/nav.html" %} |
| 48 | 122 | {% endif %} |
| 49 | 123 | |
| 50 | - <main class="{% if user.is_authenticated %}py-6{% endif %}"> | |
| 51 | - <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> | |
| 52 | - {% if messages %} | |
| 53 | - <div id="messages" class="mb-4 space-y-2"> | |
| 54 | - {% for message in messages %} | |
| 55 | - <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)" | |
| 56 | - x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}"> | |
| 57 | - <div class="flex justify-between"> | |
| 58 | - <p class="text-sm font-medium">{{ message }}</p> | |
| 59 | - <button @click="show = false" class="ml-3 text-sm font-medium underline">×</button> | |
| 60 | - </div> | |
| 61 | - </div> | |
| 62 | - {% endfor %} | |
| 63 | - </div> | |
| 64 | - {% endif %} | |
| 65 | - | |
| 66 | - {% block content %}{% endblock %} | |
| 67 | - </div> | |
| 68 | - </main> | |
| 124 | + <div class="flex flex-1 overflow-hidden"> | |
| 125 | + {% if user.is_authenticated %} | |
| 126 | + {% include "includes/sidebar.html" %} | |
| 127 | + {% endif %} | |
| 128 | + | |
| 129 | + <main class="flex-1 overflow-y-auto {% if user.is_authenticated %}py-6{% endif %}"> | |
| 130 | + <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> | |
| 131 | + {% if messages %} | |
| 132 | + <div id="messages" class="mb-4 space-y-2"> | |
| 133 | + {% for message in messages %} | |
| 134 | + <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)" | |
| 135 | + x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}"> | |
| 136 | + <div class="flex justify-between"> | |
| 137 | + <p class="text-sm font-medium">{{ message }}</p> | |
| 138 | + <button @click="show = false" class="ml-3 text-sm font-medium underline">×</button> | |
| 139 | + </div> | |
| 140 | + </div> | |
| 141 | + {% endfor %} | |
| 142 | + </div> | |
| 143 | + {% endif %} | |
| 144 | + | |
| 145 | + {% block content %}{% endblock %} | |
| 146 | + </div> | |
| 147 | + </main> | |
| 148 | + </div> | |
| 69 | 149 | </div> |
| 70 | 150 | </body> |
| 71 | 151 | </html> |
| 72 | 152 |
| --- templates/base.html | |
| +++ templates/base.html | |
| @@ -1,15 +1,28 @@ | |
| 1 | <!DOCTYPE html> |
| 2 | <html lang="en" class="h-full bg-gray-950"> |
| 3 | <head> |
| 4 | <meta charset="utf-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | <meta name="csrf-token" content="{{ csrf_token }}"> |
| 7 | <title>{% block title %}Fossilrepo{% endblock %}</title> |
| 8 | <script src="https://cdn.tailwindcss.com"></script> |
| 9 | <script> |
| 10 | tailwind.config = { |
| 11 | theme: { |
| 12 | extend: { |
| 13 | colors: { |
| 14 | brand: { |
| 15 | DEFAULT: '#DC394C', |
| @@ -25,15 +38,76 @@ | |
| 25 | <style type="text/tailwindcss"> |
| 26 | @layer base { |
| 27 | input[type="text"], input[type="number"], input[type="email"], |
| 28 | input[type="password"], input[type="search"], input[type="url"], |
| 29 | textarea, select { |
| 30 | @apply bg-gray-800 border-gray-700 text-gray-100 rounded-md shadow-sm |
| 31 | focus:border-brand focus:ring-brand sm:text-sm; |
| 32 | } |
| 33 | } |
| 34 | </style> |
| 35 | <script src="https://unpkg.com/[email protected]"></script> |
| 36 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 37 | <script> |
| 38 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 39 | var token = document.querySelector('meta[name="csrf-token"]'); |
| @@ -40,32 +114,38 @@ | |
| 40 | if (token) { event.detail.headers['X-CSRFToken'] = token.content; } |
| 41 | }); |
| 42 | </script> |
| 43 | </head> |
| 44 | <body class="h-full bg-gray-950 text-gray-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> |
| 45 | <div class="min-h-full"> |
| 46 | {% if user.is_authenticated %} |
| 47 | {% include "includes/nav.html" %} |
| 48 | {% endif %} |
| 49 | |
| 50 | <main class="{% if user.is_authenticated %}py-6{% endif %}"> |
| 51 | <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> |
| 52 | {% if messages %} |
| 53 | <div id="messages" class="mb-4 space-y-2"> |
| 54 | {% for message in messages %} |
| 55 | <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)" |
| 56 | x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}"> |
| 57 | <div class="flex justify-between"> |
| 58 | <p class="text-sm font-medium">{{ message }}</p> |
| 59 | <button @click="show = false" class="ml-3 text-sm font-medium underline">×</button> |
| 60 | </div> |
| 61 | </div> |
| 62 | {% endfor %} |
| 63 | </div> |
| 64 | {% endif %} |
| 65 | |
| 66 | {% block content %}{% endblock %} |
| 67 | </div> |
| 68 | </main> |
| 69 | </div> |
| 70 | </body> |
| 71 | </html> |
| 72 |
| --- templates/base.html | |
| +++ templates/base.html | |
| @@ -1,15 +1,28 @@ | |
| 1 | <!DOCTYPE html> |
| 2 | <html lang="en" class="h-full dark"> |
| 3 | <head> |
| 4 | <meta charset="utf-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | <meta name="csrf-token" content="{{ csrf_token }}"> |
| 7 | <title>{% block title %}Fossilrepo{% endblock %}</title> |
| 8 | <script> |
| 9 | // Apply theme before anything renders to prevent flash |
| 10 | (function() { |
| 11 | var theme = localStorage.getItem('theme'); |
| 12 | if (theme === 'light') { |
| 13 | document.documentElement.classList.remove('dark'); |
| 14 | } else { |
| 15 | document.documentElement.classList.add('dark'); |
| 16 | } |
| 17 | })(); |
| 18 | </script> |
| 19 | <script src="https://cdn.tailwindcss.com?plugins=typography"></script> |
| 20 | {% block extra_head %}{% endblock %} |
| 21 | <script> |
| 22 | tailwind.config = { |
| 23 | darkMode: 'class', |
| 24 | theme: { |
| 25 | extend: { |
| 26 | colors: { |
| 27 | brand: { |
| 28 | DEFAULT: '#DC394C', |
| @@ -25,15 +38,76 @@ | |
| 38 | <style type="text/tailwindcss"> |
| 39 | @layer base { |
| 40 | input[type="text"], input[type="number"], input[type="email"], |
| 41 | input[type="password"], input[type="search"], input[type="url"], |
| 42 | textarea, select { |
| 43 | @apply bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 |
| 44 | text-gray-900 dark:text-gray-100 rounded-md shadow-sm |
| 45 | focus:border-brand focus:ring-brand sm:text-sm; |
| 46 | } |
| 47 | } |
| 48 | </style> |
| 49 | <style> |
| 50 | /* |
| 51 | * Light mode — matches Django admin dark_theme.css palette |
| 52 | * Brand: #DC394C red, #8B3138 crimson, #2B2D2C charcoal |
| 53 | * Nav bar stays dark. Only main content area switches. |
| 54 | */ |
| 55 | html:not(.dark) body { background-color: #f8f8f8; color: #1a1a1a; } |
| 56 | /* Surfaces */ |
| 57 | html:not(.dark) main .bg-gray-950 { background-color: #f8f8f8 !important; } |
| 58 | html:not(.dark) main .bg-gray-900 { background-color: #eeeeee !important; } |
| 59 | html:not(.dark) main .bg-gray-800 { background-color: #ffffff !important; } |
| 60 | html:not(.dark) main .bg-gray-700 { background-color: #eeeeee !important; } |
| 61 | html:not(.dark) main .bg-gray-800\/50, |
| 62 | html:not(.dark) main .hover\:bg-gray-800\/50:hover { background-color: #eeeeee !important; } |
| 63 | html:not(.dark) main .hover\:bg-gray-800:hover { background-color: #eeeeee !important; } |
| 64 | html:not(.dark) main .hover\:bg-gray-700:hover { background-color: #e0e0e0 !important; } |
| 65 | html:not(.dark) main .hover\:bg-gray-700\/50:hover { background-color: #eeeeee !important; } |
| 66 | html:not(.dark) main .hover\:bg-gray-600:hover { background-color: #e0e0e0 !important; } |
| 67 | /* Text */ |
| 68 | html:not(.dark) main .text-gray-100 { color: #1a1a1a !important; } |
| 69 | html:not(.dark) main .text-gray-200 { color: #1a1a1a !important; } |
| 70 | html:not(.dark) main .text-gray-300 { color: #666666 !important; } |
| 71 | html:not(.dark) main .text-gray-400 { color: #666666 !important; } |
| 72 | html:not(.dark) main .text-gray-500 { color: #a8aaa9 !important; } |
| 73 | html:not(.dark) main .hover\:text-white:hover { color: #000000 !important; } |
| 74 | html:not(.dark) main .hover\:text-gray-200:hover { color: #1a1a1a !important; } |
| 75 | /* Borders — matches --hairline-color / --border-color */ |
| 76 | html:not(.dark) main .border-gray-700 { border-color: #e0e0e0 !important; } |
| 77 | html:not(.dark) main .border-gray-600 { border-color: #e0e0e0 !important; } |
| 78 | html:not(.dark) main .divide-gray-700 > :not([hidden]) ~ :not([hidden]) { border-color: #e0e0e0 !important; } |
| 79 | html:not(.dark) main .ring-gray-700 { --tw-ring-color: #e0e0e0 !important; } |
| 80 | html:not(.dark) main .ring-gray-600 { --tw-ring-color: #e0e0e0 !important; } |
| 81 | html:not(.dark) main .shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05) !important; } |
| 82 | /* Status badges — matches admin message colors */ |
| 83 | html:not(.dark) main .bg-green-900\/50 { background-color: #d4edda !important; } |
| 84 | html:not(.dark) main .text-green-300 { color: #155724 !important; } |
| 85 | html:not(.dark) main .border-green-700 { border-color: #c3e6cb !important; } |
| 86 | html:not(.dark) main .bg-yellow-900\/50 { background-color: #fff3cd !important; } |
| 87 | html:not(.dark) main .text-yellow-300 { color: #856404 !important; } |
| 88 | html:not(.dark) main .text-yellow-400 { color: #856404 !important; } |
| 89 | html:not(.dark) main .bg-purple-900\/50 { background-color: #f3e8ff !important; } |
| 90 | html:not(.dark) main .text-purple-300 { color: #9333ea !important; } |
| 91 | html:not(.dark) main .bg-blue-900\/50 { background-color: #dbeafe !important; } |
| 92 | html:not(.dark) main .text-blue-300 { color: #2563eb !important; } |
| 93 | html:not(.dark) main .bg-red-900\/50 { background-color: #f8d7da !important; } |
| 94 | html:not(.dark) main .text-red-300 { color: #721c24 !important; } |
| 95 | html:not(.dark) main .border-red-700 { border-color: #f5c6cb !important; } |
| 96 | html:not(.dark) main .text-red-400 { color: #c0392b !important; } |
| 97 | html:not(.dark) main .hover\:text-red-300:hover { color: #721c24 !important; } |
| 98 | /* Mono text */ |
| 99 | html:not(.dark) main .font-mono { color: #666666 !important; } |
| 100 | /* Prose — matches admin link/text colors */ |
| 101 | html:not(.dark) main .prose-invert { --tw-prose-body: #1a1a1a; --tw-prose-headings: #000000; --tw-prose-links: #DC394C; --tw-prose-bold: #000000; --tw-prose-code: #1a1a1a; --tw-prose-th-borders: #e0e0e0; --tw-prose-td-borders: #e0e0e0; } |
| 102 | /* Brand links — matches admin --link-fg */ |
| 103 | html:not(.dark) main .text-brand-light { color: #DC394C !important; } |
| 104 | html:not(.dark) main .hover\:text-brand:hover { color: #8B3138 !important; } |
| 105 | html:not(.dark) main .hover\:border-brand:hover { border-color: #DC394C !important; } |
| 106 | /* Selected/hover rows — matches admin --selected-bg */ |
| 107 | html:not(.dark) main .group:hover { border-color: #DC394C !important; } |
| 108 | </style> |
| 109 | <script src="https://unpkg.com/[email protected]"></script> |
| 110 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 111 | <script> |
| 112 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 113 | var token = document.querySelector('meta[name="csrf-token"]'); |
| @@ -40,32 +114,38 @@ | |
| 114 | if (token) { event.detail.headers['X-CSRFToken'] = token.content; } |
| 115 | }); |
| 116 | </script> |
| 117 | </head> |
| 118 | <body class="h-full bg-gray-950 text-gray-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> |
| 119 | <div class="min-h-full flex flex-col"> |
| 120 | {% if user.is_authenticated %} |
| 121 | {% include "includes/nav.html" %} |
| 122 | {% endif %} |
| 123 | |
| 124 | <div class="flex flex-1 overflow-hidden"> |
| 125 | {% if user.is_authenticated %} |
| 126 | {% include "includes/sidebar.html" %} |
| 127 | {% endif %} |
| 128 | |
| 129 | <main class="flex-1 overflow-y-auto {% if user.is_authenticated %}py-6{% endif %}"> |
| 130 | <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> |
| 131 | {% if messages %} |
| 132 | <div id="messages" class="mb-4 space-y-2"> |
| 133 | {% for message in messages %} |
| 134 | <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)" |
| 135 | x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}"> |
| 136 | <div class="flex justify-between"> |
| 137 | <p class="text-sm font-medium">{{ message }}</p> |
| 138 | <button @click="show = false" class="ml-3 text-sm font-medium underline">×</button> |
| 139 | </div> |
| 140 | </div> |
| 141 | {% endfor %} |
| 142 | </div> |
| 143 | {% endif %} |
| 144 | |
| 145 | {% block content %}{% endblock %} |
| 146 | </div> |
| 147 | </main> |
| 148 | </div> |
| 149 | </div> |
| 150 | </body> |
| 151 | </html> |
| 152 |
| --- templates/dashboard.html | ||
| +++ templates/dashboard.html | ||
| @@ -6,14 +6,35 @@ | ||
| 6 | 6 | <div class="md:flex md:items-center md:justify-between mb-8"> |
| 7 | 7 | <h1 class="text-2xl font-bold text-gray-100">Dashboard</h1> |
| 8 | 8 | </div> |
| 9 | 9 | |
| 10 | 10 | <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> |
| 11 | - {% if perms.items.view_item %} | |
| 12 | - <a href="{% url 'items:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> | |
| 13 | - <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Items</h3> | |
| 14 | - <p class="mt-2 text-sm text-gray-400">Manage your item catalogue with full CRUD operations.</p> | |
| 11 | + {% if perms.projects.view_project %} | |
| 12 | + <a href="{% url 'projects:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> | |
| 13 | + <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Projects</h3> | |
| 14 | + <p class="mt-2 text-sm text-gray-400">Manage projects and their team access controls.</p> | |
| 15 | + </a> | |
| 16 | + {% endif %} | |
| 17 | + | |
| 18 | + {% if perms.organization.view_team %} | |
| 19 | + <a href="{% url 'organization:team_list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> | |
| 20 | + <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Teams</h3> | |
| 21 | + <p class="mt-2 text-sm text-gray-400">Organize members into teams for project access.</p> | |
| 22 | + </a> | |
| 23 | + {% endif %} | |
| 24 | + | |
| 25 | + {% if perms.pages.view_page %} | |
| 26 | + <a href="{% url 'pages:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> | |
| 27 | + <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Docs</h3> | |
| 28 | + <p class="mt-2 text-sm text-gray-400">Org-wide documentation, guides, and runbooks.</p> | |
| 29 | + </a> | |
| 30 | + {% endif %} | |
| 31 | + | |
| 32 | + {% if perms.organization.view_organization %} | |
| 33 | + <a href="{% url 'organization:settings' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> | |
| 34 | + <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Settings</h3> | |
| 35 | + <p class="mt-2 text-sm text-gray-400">Organization settings, members, and configuration.</p> | |
| 15 | 36 | </a> |
| 16 | 37 | {% endif %} |
| 17 | 38 | |
| 18 | 39 | {% if user.is_staff %} |
| 19 | 40 | <a href="{% url 'admin:index' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> |
| @@ -22,12 +43,12 @@ | ||
| 22 | 43 | </a> |
| 23 | 44 | {% endif %} |
| 24 | 45 | |
| 25 | 46 | <div class="rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm"> |
| 26 | 47 | <div class="flex items-center gap-4 mb-3"> |
| 27 | - <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-8 w-auto"> | |
| 48 | + <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-8 w-auto"> | |
| 28 | 49 | </div> |
| 29 | 50 | <h3 class="text-lg font-semibold text-gray-100">Welcome, {{ user.get_full_name|default:user.username }}</h3> |
| 30 | - <p class="mt-2 text-sm text-gray-400">This is the Fossilrepo Django + HTMX template. Server-rendered with progressive enhancement via HTMX and Alpine.js.</p> | |
| 51 | + <p class="mt-2 text-sm text-gray-400">Self-hosted Fossil forge with Django + HTMX management layer.</p> | |
| 31 | 52 | </div> |
| 32 | 53 | </div> |
| 33 | 54 | {% endblock %} |
| 34 | 55 | |
| 35 | 56 | ADDED templates/fossil/_project_nav.html |
| 36 | 57 | ADDED templates/fossil/code_browser.html |
| 37 | 58 | ADDED templates/fossil/code_file.html |
| 38 | 59 | ADDED templates/fossil/forum_list.html |
| 39 | 60 | ADDED templates/fossil/forum_thread.html |
| 40 | 61 | ADDED templates/fossil/partials/file_tree.html |
| 41 | 62 | ADDED templates/fossil/partials/ticket_table.html |
| 42 | 63 | ADDED templates/fossil/partials/timeline_entries.html |
| 43 | 64 | ADDED templates/fossil/ticket_detail.html |
| 44 | 65 | ADDED templates/fossil/ticket_list.html |
| 45 | 66 | ADDED templates/fossil/timeline.html |
| 46 | 67 | ADDED templates/fossil/wiki_list.html |
| 47 | 68 | ADDED templates/fossil/wiki_page.html |
| --- templates/dashboard.html | |
| +++ templates/dashboard.html | |
| @@ -6,14 +6,35 @@ | |
| 6 | <div class="md:flex md:items-center md:justify-between mb-8"> |
| 7 | <h1 class="text-2xl font-bold text-gray-100">Dashboard</h1> |
| 8 | </div> |
| 9 | |
| 10 | <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> |
| 11 | {% if perms.items.view_item %} |
| 12 | <a href="{% url 'items:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> |
| 13 | <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Items</h3> |
| 14 | <p class="mt-2 text-sm text-gray-400">Manage your item catalogue with full CRUD operations.</p> |
| 15 | </a> |
| 16 | {% endif %} |
| 17 | |
| 18 | {% if user.is_staff %} |
| 19 | <a href="{% url 'admin:index' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> |
| @@ -22,12 +43,12 @@ | |
| 22 | </a> |
| 23 | {% endif %} |
| 24 | |
| 25 | <div class="rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm"> |
| 26 | <div class="flex items-center gap-4 mb-3"> |
| 27 | <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-8 w-auto"> |
| 28 | </div> |
| 29 | <h3 class="text-lg font-semibold text-gray-100">Welcome, {{ user.get_full_name|default:user.username }}</h3> |
| 30 | <p class="mt-2 text-sm text-gray-400">This is the Fossilrepo Django + HTMX template. Server-rendered with progressive enhancement via HTMX and Alpine.js.</p> |
| 31 | </div> |
| 32 | </div> |
| 33 | {% endblock %} |
| 34 | |
| 35 | DDED templates/fossil/_project_nav.html |
| 36 | DDED templates/fossil/code_browser.html |
| 37 | DDED templates/fossil/code_file.html |
| 38 | DDED templates/fossil/forum_list.html |
| 39 | DDED templates/fossil/forum_thread.html |
| 40 | DDED templates/fossil/partials/file_tree.html |
| 41 | DDED templates/fossil/partials/ticket_table.html |
| 42 | DDED templates/fossil/partials/timeline_entries.html |
| 43 | DDED templates/fossil/ticket_detail.html |
| 44 | DDED templates/fossil/ticket_list.html |
| 45 | DDED templates/fossil/timeline.html |
| 46 | DDED templates/fossil/wiki_list.html |
| 47 | DDED templates/fossil/wiki_page.html |
| --- templates/dashboard.html | |
| +++ templates/dashboard.html | |
| @@ -6,14 +6,35 @@ | |
| 6 | <div class="md:flex md:items-center md:justify-between mb-8"> |
| 7 | <h1 class="text-2xl font-bold text-gray-100">Dashboard</h1> |
| 8 | </div> |
| 9 | |
| 10 | <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> |
| 11 | {% if perms.projects.view_project %} |
| 12 | <a href="{% url 'projects:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> |
| 13 | <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Projects</h3> |
| 14 | <p class="mt-2 text-sm text-gray-400">Manage projects and their team access controls.</p> |
| 15 | </a> |
| 16 | {% endif %} |
| 17 | |
| 18 | {% if perms.organization.view_team %} |
| 19 | <a href="{% url 'organization:team_list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> |
| 20 | <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Teams</h3> |
| 21 | <p class="mt-2 text-sm text-gray-400">Organize members into teams for project access.</p> |
| 22 | </a> |
| 23 | {% endif %} |
| 24 | |
| 25 | {% if perms.pages.view_page %} |
| 26 | <a href="{% url 'pages:list' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> |
| 27 | <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Docs</h3> |
| 28 | <p class="mt-2 text-sm text-gray-400">Org-wide documentation, guides, and runbooks.</p> |
| 29 | </a> |
| 30 | {% endif %} |
| 31 | |
| 32 | {% if perms.organization.view_organization %} |
| 33 | <a href="{% url 'organization:settings' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> |
| 34 | <h3 class="text-lg font-semibold text-gray-100 group-hover:text-brand">Settings</h3> |
| 35 | <p class="mt-2 text-sm text-gray-400">Organization settings, members, and configuration.</p> |
| 36 | </a> |
| 37 | {% endif %} |
| 38 | |
| 39 | {% if user.is_staff %} |
| 40 | <a href="{% url 'admin:index' %}" class="group rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm hover:shadow-md hover:border-brand transition-all"> |
| @@ -22,12 +43,12 @@ | |
| 43 | </a> |
| 44 | {% endif %} |
| 45 | |
| 46 | <div class="rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-sm"> |
| 47 | <div class="flex items-center gap-4 mb-3"> |
| 48 | <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-8 w-auto"> |
| 49 | </div> |
| 50 | <h3 class="text-lg font-semibold text-gray-100">Welcome, {{ user.get_full_name|default:user.username }}</h3> |
| 51 | <p class="mt-2 text-sm text-gray-400">Self-hosted Fossil forge with Django + HTMX management layer.</p> |
| 52 | </div> |
| 53 | </div> |
| 54 | {% endblock %} |
| 55 | |
| 56 | DDED templates/fossil/_project_nav.html |
| 57 | DDED templates/fossil/code_browser.html |
| 58 | DDED templates/fossil/code_file.html |
| 59 | DDED templates/fossil/forum_list.html |
| 60 | DDED templates/fossil/forum_thread.html |
| 61 | DDED templates/fossil/partials/file_tree.html |
| 62 | DDED templates/fossil/partials/ticket_table.html |
| 63 | DDED templates/fossil/partials/timeline_entries.html |
| 64 | DDED templates/fossil/ticket_detail.html |
| 65 | DDED templates/fossil/ticket_list.html |
| 66 | DDED templates/fossil/timeline.html |
| 67 | DDED templates/fossil/wiki_list.html |
| 68 | DDED templates/fossil/wiki_page.html |
| --- a/templates/fossil/_project_nav.html | ||
| +++ b/templates/fossil/_project_nav.html | ||
| @@ -0,0 +1,26 @@ | ||
| 1 | +<nav class="flex space-x-1 border-b border-gray-700 mb-6 overflow-x-auto"> | |
| 2 | + <a href="{% url 'projects:detail' slug=project.slug %}" | |
| 3 | + class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'overview' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> | |
| 4 | + Overview | |
| 5 | + </a> | |
| 6 | + <a href="{% url 'fossil:code' slug=project.slug %}" | |
| 7 | + class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'code' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> | |
| 8 | + Code | |
| 9 | + </a> | |
| 10 | + <a href="{% url 'fossil:timeline' slug=project.slug %}" | |
| 11 | + class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'timeline' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> | |
| 12 | + Timeline | |
| 13 | + </a> | |
| 14 | + <a href="{% url 'fossil:tickets' slug=project.slug %}" | |
| 15 | + class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'tickets' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> | |
| 16 | + Tickets | |
| 17 | + </a> | |
| 18 | + <a href="{% url 'fossil:wiki' slug=project.slug %}" | |
| 19 | + class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'wiki' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> | |
| 20 | + Wiki | |
| 21 | + </a> | |
| 22 | + <a href="{% url 'fossil:forum' slug=project.slug %}" | |
| 23 | + class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> | |
| 24 | + Forum | |
| 25 | + </a> | |
| 26 | +</nav> |
| --- a/templates/fossil/_project_nav.html | |
| +++ b/templates/fossil/_project_nav.html | |
| @@ -0,0 +1,26 @@ | |
| --- a/templates/fossil/_project_nav.html | |
| +++ b/templates/fossil/_project_nav.html | |
| @@ -0,0 +1,26 @@ | |
| 1 | <nav class="flex space-x-1 border-b border-gray-700 mb-6 overflow-x-auto"> |
| 2 | <a href="{% url 'projects:detail' slug=project.slug %}" |
| 3 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'overview' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 4 | Overview |
| 5 | </a> |
| 6 | <a href="{% url 'fossil:code' slug=project.slug %}" |
| 7 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'code' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 8 | Code |
| 9 | </a> |
| 10 | <a href="{% url 'fossil:timeline' slug=project.slug %}" |
| 11 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'timeline' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 12 | Timeline |
| 13 | </a> |
| 14 | <a href="{% url 'fossil:tickets' slug=project.slug %}" |
| 15 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'tickets' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 16 | Tickets |
| 17 | </a> |
| 18 | <a href="{% url 'fossil:wiki' slug=project.slug %}" |
| 19 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'wiki' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 20 | Wiki |
| 21 | </a> |
| 22 | <a href="{% url 'fossil:forum' slug=project.slug %}" |
| 23 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 24 | Forum |
| 25 | </a> |
| 26 | </nav> |
| --- a/templates/fossil/code_browser.html | ||
| +++ b/templates/fossil/code_browser.html | ||
| @@ -0,0 +1,34 @@ | ||
| 1 | +rent_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %} | |
| 2 | + | |
| 3 | +{% block content %} | |
| 4 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 5 | +{% include "fossil/_project_nav.html" %} | |
| 6 | + | |
| 7 | +<div class="overflow-hidden rounded-lg bg-gray-800 shadow bordeLatest commit bar --> | |
| 8 | +- Latest commit info --> | |
| 9 | + adcrumb + commit ba adata %} | |
| 10 | + <dijustify-between"> | |
| 11 | + <!-- Breadcrumbs --> | |
| 12 | + <digap-3 min-w-0"> | |
| 13 | + <span class="text-sm y-500"> | |
| 14 | + <span cl200 flex-shrink-0oad humanize %} | |
| 15 | +{% {% e }}</span> | |
| 16 | + {% else %} | |
| 17 | + <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a> | |
| 18 | + {% endif %} | |
| 19 | + {% endfor %} | |
| 20 | + </div> | |
| 21 | + {% if metadata %} | |
| 22 | + <div class="flex items-center gap-2 flex-shrink-0 text-xs text-gray-500"> | |
| 23 | + <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 24 | + <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> | |
| 25 | + </svg> | |
| 26 | + {{ metadata.checkin_count }} commit{{ metadata.checkin_count|pluralize }} | |
| 27 | + </div> | |
| 28 | + {% endif %} | |
| 29 | + </div> | |
| 30 | + | |
| 31 | + <!-- Latest commit info --> | |
| 32 | + {% if latest_commit %} | |
| 33 | + <div class="flex items-center gap-3 mt-2 text-xs text-gray-500"> | |
| 34 | + <span class="font-medium text-gray-300">{{ latest_commit.user }}</span> |
| --- a/templates/fossil/code_browser.html | |
| +++ b/templates/fossil/code_browser.html | |
| @@ -0,0 +1,34 @@ | |
| --- a/templates/fossil/code_browser.html | |
| +++ b/templates/fossil/code_browser.html | |
| @@ -0,0 +1,34 @@ | |
| 1 | rent_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %} |
| 2 | |
| 3 | {% block content %} |
| 4 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 5 | {% include "fossil/_project_nav.html" %} |
| 6 | |
| 7 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow bordeLatest commit bar --> |
| 8 | - Latest commit info --> |
| 9 | adcrumb + commit ba adata %} |
| 10 | <dijustify-between"> |
| 11 | <!-- Breadcrumbs --> |
| 12 | <digap-3 min-w-0"> |
| 13 | <span class="text-sm y-500"> |
| 14 | <span cl200 flex-shrink-0oad humanize %} |
| 15 | {% {% e }}</span> |
| 16 | {% else %} |
| 17 | <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a> |
| 18 | {% endif %} |
| 19 | {% endfor %} |
| 20 | </div> |
| 21 | {% if metadata %} |
| 22 | <div class="flex items-center gap-2 flex-shrink-0 text-xs text-gray-500"> |
| 23 | <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 24 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> |
| 25 | </svg> |
| 26 | {{ metadata.checkin_count }} commit{{ metadata.checkin_count|pluralize }} |
| 27 | </div> |
| 28 | {% endif %} |
| 29 | </div> |
| 30 | |
| 31 | <!-- Latest commit info --> |
| 32 | {% if latest_commit %} |
| 33 | <div class="flex items-center gap-3 mt-2 text-xs text-gray-500"> |
| 34 | <span class="font-medium text-gray-300">{{ latest_commit.user }}</span> |
| --- a/templates/fossil/code_file.html | ||
| +++ b/templates/fossil/code_file.html | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block extra_head %} | |
| 5 | +<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> | |
| 6 | +<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> | |
| 7 | +{% endblock %} | |
| 8 | + | |
| 9 | +{% block content %} | |
| 10 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 11 | +{% include "fossil/_project_nav.html" %} | |
| 12 | + | |
| 13 | +<div class="mb-4"> | |
| 14 | + <a href="{% url 'fossil:code' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to files</a> | |
| 15 | +</div> | |
| 16 | + | |
| 17 | +<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> | |
| 18 | + <div class="px-4 py-3 border-b border-gray-700"> | |
| 19 | + <span class="font-mono text-sm text-gray-300">{{ filepath }}</span> | |
| 20 | + </div> | |
| 21 | + <div class="overflow-x-auto"> | |
| 22 | + {% if is_binary %} | |
| 23 | + <p class="p-4 text-sm text-gray-500">{{ content }}</p> | |
| 24 | + {% else %} | |
| 25 | + <pre class="p-0 m-0"><code class="language-{{ language }} text-sm leading-relaxed">{{ content }}</code></pre> | |
| 26 | + {% endif %} | |
| 27 | + </div> | |
| 28 | +</div> | |
| 29 | + | |
| 30 | +{% if not is_binary %} | |
| 31 | +<script>hljs.highlightAll();</script> | |
| 32 | +{% endif %} | |
| 33 | +{% endblock %} |
| --- a/templates/fossil/code_file.html | |
| +++ b/templates/fossil/code_file.html | |
| @@ -0,0 +1,33 @@ | |
| --- a/templates/fossil/code_file.html | |
| +++ b/templates/fossil/code_file.html | |
| @@ -0,0 +1,33 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block extra_head %} |
| 5 | <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> |
| 6 | <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> |
| 7 | {% endblock %} |
| 8 | |
| 9 | {% block content %} |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 11 | {% include "fossil/_project_nav.html" %} |
| 12 | |
| 13 | <div class="mb-4"> |
| 14 | <a href="{% url 'fossil:code' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to files</a> |
| 15 | </div> |
| 16 | |
| 17 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 18 | <div class="px-4 py-3 border-b border-gray-700"> |
| 19 | <span class="font-mono text-sm text-gray-300">{{ filepath }}</span> |
| 20 | </div> |
| 21 | <div class="overflow-x-auto"> |
| 22 | {% if is_binary %} |
| 23 | <p class="p-4 text-sm text-gray-500">{{ content }}</p> |
| 24 | {% else %} |
| 25 | <pre class="p-0 m-0"><code class="language-{{ language }} text-sm leading-relaxed">{{ content }}</code></pre> |
| 26 | {% endif %} |
| 27 | </div> |
| 28 | </div> |
| 29 | |
| 30 | {% if not is_binary %} |
| 31 | <script>hljs.highlightAll();</script> |
| 32 | {% endif %} |
| 33 | {% endblock %} |
| --- a/templates/fossil/forum_list.html | ||
| +++ b/templates/fossil/forum_list.html | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% load fossil_filters %} | |
| 3 | +{% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %} | |
| 4 | + | |
| 5 | +{% block content %} | |
| 6 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 7 | +d | |
| 8 | + </a> | |
| 9 | + {% endif %} | |
| 10 | + </div>2</div> | |
| 11 | + | |
| 12 | +<div id="forum-content"> | |
| 13 | +<div class="space-y-3"> | |
| 14 | + {% for post in posts %} | |
| 15 | + <divpx-4 py-3"> | |
| 16 | +ends "base.html" %} | |
| 17 | +{% load fossil_filters %} | |
| 18 | +{% block title %}Forum — {{ project.name }} {% exteorder-brand focus:ring-b/path></s font-medium text-sm"> | |
| 19 | + ="inline-flex item</a> | |
| 20 | + <div'fossil:forum_thre{{ post.user }} · {{ post.timestamp|date:"N j, Y g:i a" }} |
| --- a/templates/fossil/forum_list.html | |
| +++ b/templates/fossil/forum_list.html | |
| @@ -0,0 +1,20 @@ | |
| --- a/templates/fossil/forum_list.html | |
| +++ b/templates/fossil/forum_list.html | |
| @@ -0,0 +1,20 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | d |
| 8 | </a> |
| 9 | {% endif %} |
| 10 | </div>2</div> |
| 11 | |
| 12 | <div id="forum-content"> |
| 13 | <div class="space-y-3"> |
| 14 | {% for post in posts %} |
| 15 | <divpx-4 py-3"> |
| 16 | ends "base.html" %} |
| 17 | {% load fossil_filters %} |
| 18 | {% block title %}Forum — {{ project.name }} {% exteorder-brand focus:ring-b/path></s font-medium text-sm"> |
| 19 | ="inline-flex item</a> |
| 20 | <div'fossil:forum_thre{{ post.user }} · {{ post.timestamp|date:"N j, Y g:i a" }} |
| --- a/templates/fossil/forum_thread.html | ||
| +++ b/templates/fossil/forum_thread.html | ||
| @@ -0,0 +1,27 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Forum Thread — {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 6 | +{% include "fossil/_project_nav.html" %} | |
| 7 | + | |
| 8 | +<div class="mb-4"> | |
| 9 | + <a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to forum</a> | |
| 10 | +</div> | |
| 11 | + | |
| 12 | +<div class="space-y-3"> | |
| 13 | + {% for post in posts %} | |
| 14 | + <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4"> | |
| 15 | + <div class="flex items-center justify-between mb-2"> | |
| 16 | + <span class="text-sm font-medium text-gray-200">{{ post.user }}</span> | |
| 17 | + <span class="text-xs text-gray-500">{{ post.timestamp|date:"N j, Y g:i a" }}</span> | |
| 18 | + </div> | |
| 19 | + <div class="text-sm text-gray-300"> | |
| 20 | + {{ post.title|default:post.body|default:"(empty)" }} | |
| 21 | + </div> | |
| 22 | + </div> | |
| 23 | + {% empty %} | |
| 24 | + <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p> | |
| 25 | + {% endfor %} | |
| 26 | +</div> | |
| 27 | +{% endblock %} |
| --- a/templates/fossil/forum_thread.html | |
| +++ b/templates/fossil/forum_thread.html | |
| @@ -0,0 +1,27 @@ | |
| --- a/templates/fossil/forum_thread.html | |
| +++ b/templates/fossil/forum_thread.html | |
| @@ -0,0 +1,27 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Forum Thread — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="mb-4"> |
| 9 | <a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to forum</a> |
| 10 | </div> |
| 11 | |
| 12 | <div class="space-y-3"> |
| 13 | {% for post in posts %} |
| 14 | <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4"> |
| 15 | <div class="flex items-center justify-between mb-2"> |
| 16 | <span class="text-sm font-medium text-gray-200">{{ post.user }}</span> |
| 17 | <span class="text-xs text-gray-500">{{ post.timestamp|date:"N j, Y g:i a" }}</span> |
| 18 | </div> |
| 19 | <div class="text-sm text-gray-300"> |
| 20 | {{ post.title|default:post.body|default:"(empty)" }} |
| 21 | </div> |
| 22 | </div> |
| 23 | {% empty %} |
| 24 | <p class="text-sm text-gray-500 py-8 text-center">No posts in this thread.</p> |
| 25 | {% endfor %} |
| 26 | </div> |
| 27 | {% endblock %} |
| --- a/templates/fossil/partials/file_tree.html | ||
| +++ b/templates/fossil/partials/file_tree.html | ||
| @@ -0,0 +1 @@ | ||
| 1 | +<div id="file-tree"ee" class="overflow-x-auto w-64' slug=project.slugrepository yet |
| --- a/templates/fossil/partials/file_tree.html | |
| +++ b/templates/fossil/partials/file_tree.html | |
| @@ -0,0 +1 @@ | |
| --- a/templates/fossil/partials/file_tree.html | |
| +++ b/templates/fossil/partials/file_tree.html | |
| @@ -0,0 +1 @@ | |
| 1 | <div id="file-tree"ee" class="overflow-x-auto w-64' slug=project.slugrepository yet |
| --- a/templates/fossil/partials/ticket_table.html | ||
| +++ b/templates/fossil/partials/ticket_table.html | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +<div id="ticket-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 2 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 3 | + <thead class="bg-gray-900"> | |
| 4 | + <t6> | |
| 5 | + <th class="px-4 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wi6> | |
| 6 | + <th class="px-4 py-3 tStatus</th> | |
| 7 | + <th class="px-6> | |
| 8 | + <th class="px-4 py-3 tOwner</th> | |
| 9 | + <th class="px-6> | |
| 10 | + <th class="px-4 py-3 t00/70 bg-gray-800"> | |
| 11 | + {% for ticket in tickets %} | |
| 12 | + <t50"-right text-xs font-medium upper <td class="px-4 py-3"> | |
| 13 | + <a href="{% url 'fossil:ticket_detail' slug=project.slug tic6 py-4ug ticket_uuid=ticket.uuid %}" | |
| 14 | + 6 py-46 py-4 <a href="{% u{{ ticket.owner<div id="ticket-tablden<di <a href="{% u{{ ticket.created|date:"N j, Y {% for ticket in tickets %} | |
| 15 | + <t50"-right text-xs font-medium upper4" class="px-6-medium upper <td href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}" | |
| 16 | + |
| --- a/templates/fossil/partials/ticket_table.html | |
| +++ b/templates/fossil/partials/ticket_table.html | |
| @@ -0,0 +1,16 @@ | |
| --- a/templates/fossil/partials/ticket_table.html | |
| +++ b/templates/fossil/partials/ticket_table.html | |
| @@ -0,0 +1,16 @@ | |
| 1 | <div id="ticket-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 2 | <table class="min-w-full divide-y divide-gray-700"> |
| 3 | <thead class="bg-gray-900"> |
| 4 | <t6> |
| 5 | <th class="px-4 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wi6> |
| 6 | <th class="px-4 py-3 tStatus</th> |
| 7 | <th class="px-6> |
| 8 | <th class="px-4 py-3 tOwner</th> |
| 9 | <th class="px-6> |
| 10 | <th class="px-4 py-3 t00/70 bg-gray-800"> |
| 11 | {% for ticket in tickets %} |
| 12 | <t50"-right text-xs font-medium upper <td class="px-4 py-3"> |
| 13 | <a href="{% url 'fossil:ticket_detail' slug=project.slug tic6 py-4ug ticket_uuid=ticket.uuid %}" |
| 14 | 6 py-46 py-4 <a href="{% u{{ ticket.owner<div id="ticket-tablden<di <a href="{% u{{ ticket.created|date:"N j, Y {% for ticket in tickets %} |
| 15 | <t50"-right text-xs font-medium upper4" class="px-6-medium upper <td href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}" |
| 16 |
| --- a/templates/fossil/partials/timeline_entries.html | ||
| +++ b/templates/fossil/partials/timeline_entries.html | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +<style> | |
| 2 | + .dag-col { position: relative; flex-shrink: 0; } | |
| 3 | + .dag-node { | |
| 4 | + position: absolute; top: 50%; z-index: 2; border-radius: 50%; | |
| 5 | + transform: translate(-50%, -50%); | |
| 6 | + } | |
| 7 | + .dag-node-ci { width: 12px; height: 12px; background: #DC394C; border: 2px solid #e8677a; } | |
| 8 | + .dag-node-merge { width: 12px; height: 12px; background: #DC394C; border: 2px solid #e8677a; border-radius: 2px; transform: translate(-50%, -50%) rotate(45deg); } | |
| 9 | + .dag-node-w { width: 10px; height: 10px; background: #3b82f6; border: 2px solid #60a5fa; } | |
| 10 | + .dag-node-t { width: 10px; height: 10px; background: #eab308; border: 2px solid #facc15; } | |
| 11 | + .dag-node-f { width: 10px; height: 10px; background: #a855f7; border: 2px solid #c084fc; } | |
| 12 | + .dag-node-other { width: 10px; height: 10px; background: #6b7280; border: 2px solid #9ca3af; } | |
| 13 | + .dag-vline { | |
| 14 | + position: absolute; width: 2px; top: 0; bottom: 0; | |
| 15 | + transform: tra% endfor %} | |
| 16 | + {% else %} | |
| 17 | + <n --> | |
| 18 | + <div class="dag-col" style="width: {{ item.graph_width }}px;"> | |
| 19 | + <!-- Vertical rail 500">{{ item.entry.for line in item.lines %} | |
| 20 | + <div class="dag-vline dag-vline-ci" style="left: {{ line.timesince }} ago{{ item.entry.event_ <!-- Node --> | |
| 21 | + <div class="dag-node {% if item.entry.is_merge %}dag-node-merge{% elif item.entry.event_type == 'ci' %}dag-node-ci{% elif item.entry.event_type == 'w' %}dag-node-w{% elif item.entry.event_type == 't' %}dag-node-t{% elif item.entry.event_type == 'f' %}dag-node-f{% else %}dag-node-other{% endif %}" | |
| 22 | + codee in item.lines font-monosition: relative; flex-shrink:<style> | |
| 23 | + .dag-col { positcodeendfor %} | |
| 24 | + | |
| 25 | + <!endif %} | |
| 26 | + <dth: {{ item.connector.width }}px; | |
| 27 | + border-left: 2px solid rgba(220,57,76,0.35); | |
| 28 | + border-right: 2px solid rgba(220,57,76,0.35);"></div> | |
| 29 | + {% endif %} | |
| 30 | + </div> | |
| 31 | + | |
| 32 | + <!-- Content --> | |
| 33 | + <div class="flex-1 py-1 min-w-0"> | |
| 34 | + <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-2.5 hover:border-gray-600 transition-colors"> | |
| 35 | + <div class="flex items-start justify-between gap-3"> | |
| 36 | + <div class="flex-1 min-w-0"> | |
| 37 | + {% if item.entry.event_type == "ci" %} | |
| 38 | + <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=item.entry.uuid %}" class="text-sm text-gray-100 leading-snug hover:text-brand-light">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</a> | |
| 39 | + {% else %} | |
| 40 | + <p class="text-sm text-gray-100 leading-snug">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</p> | |
| 41 | + {% endif %} | |
| 42 | + <div class="mt-1 flex items-center gap-3 flex-wrap"> | |
| 43 | + <span class="text-xs text-gray-400">{{ item.entry.user }}</span> | |
| 44 | + <span class="text-xs text-gray-600">{{ item.entry.timestamp|date:"Y-m-d H |
| --- a/templates/fossil/partials/timeline_entries.html | |
| +++ b/templates/fossil/partials/timeline_entries.html | |
| @@ -0,0 +1,44 @@ | |
| --- a/templates/fossil/partials/timeline_entries.html | |
| +++ b/templates/fossil/partials/timeline_entries.html | |
| @@ -0,0 +1,44 @@ | |
| 1 | <style> |
| 2 | .dag-col { position: relative; flex-shrink: 0; } |
| 3 | .dag-node { |
| 4 | position: absolute; top: 50%; z-index: 2; border-radius: 50%; |
| 5 | transform: translate(-50%, -50%); |
| 6 | } |
| 7 | .dag-node-ci { width: 12px; height: 12px; background: #DC394C; border: 2px solid #e8677a; } |
| 8 | .dag-node-merge { width: 12px; height: 12px; background: #DC394C; border: 2px solid #e8677a; border-radius: 2px; transform: translate(-50%, -50%) rotate(45deg); } |
| 9 | .dag-node-w { width: 10px; height: 10px; background: #3b82f6; border: 2px solid #60a5fa; } |
| 10 | .dag-node-t { width: 10px; height: 10px; background: #eab308; border: 2px solid #facc15; } |
| 11 | .dag-node-f { width: 10px; height: 10px; background: #a855f7; border: 2px solid #c084fc; } |
| 12 | .dag-node-other { width: 10px; height: 10px; background: #6b7280; border: 2px solid #9ca3af; } |
| 13 | .dag-vline { |
| 14 | position: absolute; width: 2px; top: 0; bottom: 0; |
| 15 | transform: tra% endfor %} |
| 16 | {% else %} |
| 17 | <n --> |
| 18 | <div class="dag-col" style="width: {{ item.graph_width }}px;"> |
| 19 | <!-- Vertical rail 500">{{ item.entry.for line in item.lines %} |
| 20 | <div class="dag-vline dag-vline-ci" style="left: {{ line.timesince }} ago{{ item.entry.event_ <!-- Node --> |
| 21 | <div class="dag-node {% if item.entry.is_merge %}dag-node-merge{% elif item.entry.event_type == 'ci' %}dag-node-ci{% elif item.entry.event_type == 'w' %}dag-node-w{% elif item.entry.event_type == 't' %}dag-node-t{% elif item.entry.event_type == 'f' %}dag-node-f{% else %}dag-node-other{% endif %}" |
| 22 | codee in item.lines font-monosition: relative; flex-shrink:<style> |
| 23 | .dag-col { positcodeendfor %} |
| 24 | |
| 25 | <!endif %} |
| 26 | <dth: {{ item.connector.width }}px; |
| 27 | border-left: 2px solid rgba(220,57,76,0.35); |
| 28 | border-right: 2px solid rgba(220,57,76,0.35);"></div> |
| 29 | {% endif %} |
| 30 | </div> |
| 31 | |
| 32 | <!-- Content --> |
| 33 | <div class="flex-1 py-1 min-w-0"> |
| 34 | <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-2.5 hover:border-gray-600 transition-colors"> |
| 35 | <div class="flex items-start justify-between gap-3"> |
| 36 | <div class="flex-1 min-w-0"> |
| 37 | {% if item.entry.event_type == "ci" %} |
| 38 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=item.entry.uuid %}" class="text-sm text-gray-100 leading-snug hover:text-brand-light">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</a> |
| 39 | {% else %} |
| 40 | <p class="text-sm text-gray-100 leading-snug">{{ item.entry.comment|default:"(no comment)"|truncatechars:120 }}</p> |
| 41 | {% endif %} |
| 42 | <div class="mt-1 flex items-center gap-3 flex-wrap"> |
| 43 | <span class="text-xs text-gray-400">{{ item.entry.user }}</span> |
| 44 | <span class="text-xs text-gray-600">{{ item.entry.timestamp|date:"Y-m-d H |
| --- a/templates/fossil/ticket_detail.html | ||
| +++ b/templates/fossil/ticket_detail.html | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ ticket.title }} — {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 6 | +{% include "fossil/_project_nav.html" %} | |
| 7 | + | |
| 8 | +<div class="mb-4"> | |
| 9 | + <a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to tickets</a> | |
| 10 | +</div> | |
| 11 | + | |
| 12 | +<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> | |
| 13 | + <div class="px-6 py-5 border-b border-gray-700"> | |
| 14 | + <h2 class="text-xl font-bold text-gray-100">{{ ticket.title|default:"(untitled)" }}</h2> | |
| 15 | + <p class="mt-1 text-sm text-gray-400"> | |
| 16 | + <code class="font-mono text-xs text-gray-500">{{ ticket.uuid|truncatechars:16 }}</code> | |
| 17 | + </p> | |
| 18 | + </div> | |
| 19 | + <div class="px-6 py-5"> | |
| 20 | + <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-3"> | |
| 21 | + <div> | |
| 22 | + <dt class="text-sm font-medium text-gray-400">Status</dt> | |
| 23 | + <dd class="mt-1 text-sm text-gray-100">{{ ticket.status|default:"—" }}</dd> | |
| 24 | + </div> | |
| 25 | + <div> | |
| 26 | + <dt class="text-sm font-medium text-gray-400">Type</dt> | |
| 27 | + <dd class="mt-1 text-sm text-gray-100">{{ ticket.type|default:"—" }}</dd> | |
| 28 | + </div> | |
| 29 | + <div> | |
| 30 | + <dt class="text-sm font-medium text-gray-400">Owner</dt> | |
| 31 | + <dd class="mt-1 text-sm text-gray-100">{{ ticket.owner|default:"—" }}</dd> | |
| 32 | + </div> | |
| 33 | + <div> | |
| 34 | + <dt class="text-sm font-medium text-gray-400">Priority</dt> | |
| 35 | + <dd class="mt-1 text-sm text-gray-100">{{ ticket.priority|default:"—" }}</dd> | |
| 36 | + </div> | |
| 37 | + <div> | |
| 38 | + <dt class="text-sm font-medium text-gray-400">Subsystem</dt> | |
| 39 | + <dd class="mt-1 text-sm text-gray-100">{{ ticket.subsystem|default:"—" }}</dd> | |
| 40 | + </div> | |
| 41 | + <div> | |
| 42 | + <dt class="text-sm font-medium text-gray-400">Created</dt> | |
| 43 | + <dd class="mt-1 text-sm text-gray-100">{{ ticket.created|date:"N j, Y g:i a" }}</dd> | |
| 44 | + </div> | |
| 45 | + </dl> | |
| 46 | + </div> | |
| 47 | +</div> | |
| 48 | +{% endblock %} |
| --- a/templates/fossil/ticket_detail.html | |
| +++ b/templates/fossil/ticket_detail.html | |
| @@ -0,0 +1,48 @@ | |
| --- a/templates/fossil/ticket_detail.html | |
| +++ b/templates/fossil/ticket_detail.html | |
| @@ -0,0 +1,48 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ ticket.title }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="mb-4"> |
| 9 | <a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to tickets</a> |
| 10 | </div> |
| 11 | |
| 12 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 13 | <div class="px-6 py-5 border-b border-gray-700"> |
| 14 | <h2 class="text-xl font-bold text-gray-100">{{ ticket.title|default:"(untitled)" }}</h2> |
| 15 | <p class="mt-1 text-sm text-gray-400"> |
| 16 | <code class="font-mono text-xs text-gray-500">{{ ticket.uuid|truncatechars:16 }}</code> |
| 17 | </p> |
| 18 | </div> |
| 19 | <div class="px-6 py-5"> |
| 20 | <dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-3"> |
| 21 | <div> |
| 22 | <dt class="text-sm font-medium text-gray-400">Status</dt> |
| 23 | <dd class="mt-1 text-sm text-gray-100">{{ ticket.status|default:"—" }}</dd> |
| 24 | </div> |
| 25 | <div> |
| 26 | <dt class="text-sm font-medium text-gray-400">Type</dt> |
| 27 | <dd class="mt-1 text-sm text-gray-100">{{ ticket.type|default:"—" }}</dd> |
| 28 | </div> |
| 29 | <div> |
| 30 | <dt class="text-sm font-medium text-gray-400">Owner</dt> |
| 31 | <dd class="mt-1 text-sm text-gray-100">{{ ticket.owner|default:"—" }}</dd> |
| 32 | </div> |
| 33 | <div> |
| 34 | <dt class="text-sm font-medium text-gray-400">Priority</dt> |
| 35 | <dd class="mt-1 text-sm text-gray-100">{{ ticket.priority|default:"—" }}</dd> |
| 36 | </div> |
| 37 | <div> |
| 38 | <dt class="text-sm font-medium text-gray-400">Subsystem</dt> |
| 39 | <dd class="mt-1 text-sm text-gray-100">{{ ticket.subsystem|default:"—" }}</dd> |
| 40 | </div> |
| 41 | <div> |
| 42 | <dt class="text-sm font-medium text-gray-400">Created</dt> |
| 43 | <dd class="mt-1 text-sm text-gray-100">{{ ticket.created|date:"N j, Y g:i a" }}</dd> |
| 44 | </div> |
| 45 | </dl> |
| 46 | </div> |
| 47 | </div> |
| 48 | {% endblock %} |
| --- a/templates/fossil/ticket_list.html | ||
| +++ b/templates/fossil/ticket_list.html | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Tickets — {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 6 | +{% include "fossil/_projectmb-4"> | |
| 7 | + <input type="search" | |
| 8 | + t type="search" | |
| 9 | + value="{{ search }}" | |
| 10 | + ="{{ search }}class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 11 | + hx-get="{% url 'fossil:ti" | |
| 12 | + hx-trigger="input changed delay:300ms, search" | |
| 13 | + hx-target="#ticket-table" | |
| 14 | + "#ticket-table" | |
| 15 | + hxhx-push-url="true" /-push-url="true" /> | |
| 16 | + </div> | |
| 17 | +</div> | |
| 18 | + | |
| 19 | +{% include "fossil/partials/ticket_tab |
| --- a/templates/fossil/ticket_list.html | |
| +++ b/templates/fossil/ticket_list.html | |
| @@ -0,0 +1,19 @@ | |
| --- a/templates/fossil/ticket_list.html | |
| +++ b/templates/fossil/ticket_list.html | |
| @@ -0,0 +1,19 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Tickets — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_projectmb-4"> |
| 7 | <input type="search" |
| 8 | t type="search" |
| 9 | value="{{ search }}" |
| 10 | ="{{ search }}class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 11 | hx-get="{% url 'fossil:ti" |
| 12 | hx-trigger="input changed delay:300ms, search" |
| 13 | hx-target="#ticket-table" |
| 14 | "#ticket-table" |
| 15 | hxhx-push-url="true" /-push-url="true" /> |
| 16 | </div> |
| 17 | </div> |
| 18 | |
| 19 | {% include "fossil/partials/ticket_tab |
| --- a/templates/fossil/timeline.html | ||
| +++ b/templates/fossil/timeline.html | ||
| @@ -0,0 +1,30 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Timeline — {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 6 | +{% include "fossil/_project_nav.html" %} | |
| 7 | + | |
| 8 | +<div class="flex items-center gap-2 mb-4 text-xs text-gray-500"> | |
| 9 | + <span>Filter:</span> | |
| 10 | + <a href="{% url 'fossil:timeline' slug=project.slug %}" | |
| 11 | + class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a> | |
| 12 | + <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci" | |
| 13 | + class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a> | |
| 14 | + <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w" | |
| 15 | + class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a> | |
| 16 | + <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t" | |
| 17 | + class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a> | |
| 18 | +</div> | |
| 19 | + | |
| 20 | +{% include "fossil/partials/timeline_entries.html" %} | |
| 21 | + | |
| 22 | +{% if entries|length == 50 %} | |
| 23 | +<div class="mt-4 text-center"> | |
| 24 | + <a href="{% url 'fossil:timeline' slug=project.slug %}?page={{ page|add:1 }}{% if event_type %}&type={{ event_type }}{% endif %}" | |
| 25 | + class="inline-flex items-center rounded-md bg-gray-800 px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700"> | |
| 26 | + Load more | |
| 27 | + </a> | |
| 28 | +</div> | |
| 29 | +{% endif %} | |
| 30 | +{% endblock %} |
| --- a/templates/fossil/timeline.html | |
| +++ b/templates/fossil/timeline.html | |
| @@ -0,0 +1,30 @@ | |
| --- a/templates/fossil/timeline.html | |
| +++ b/templates/fossil/timeline.html | |
| @@ -0,0 +1,30 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Timeline — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="flex items-center gap-2 mb-4 text-xs text-gray-500"> |
| 9 | <span>Filter:</span> |
| 10 | <a href="{% url 'fossil:timeline' slug=project.slug %}" |
| 11 | class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a> |
| 12 | <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci" |
| 13 | class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a> |
| 14 | <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w" |
| 15 | class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a> |
| 16 | <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t" |
| 17 | class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a> |
| 18 | </div> |
| 19 | |
| 20 | {% include "fossil/partials/timeline_entries.html" %} |
| 21 | |
| 22 | {% if entries|length == 50 %} |
| 23 | <div class="mt-4 text-center"> |
| 24 | <a href="{% url 'fossil:timeline' slug=project.slug %}?page={{ page|add:1 }}{% if event_type %}&type={{ event_type }}{% endif %}" |
| 25 | class="inline-flex items-center rounded-md bg-gray-800 px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700"> |
| 26 | Load more |
| 27 | </a> |
| 28 | </div> |
| 29 | {% endif %} |
| 30 | {% endblock %} |
| --- a/templates/fossil/wiki_list.html | ||
| +++ b/templates/fossil/wiki_list.html | ||
| @@ -0,0 +1,6 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% blotext-gray-400 hx-swap="innerHTML{ p.name }} | |
| 3 | +ged dela <h2 class="text-lg font-semibold text-gray-100">{{ home_page.name }}</h2> | |
| 4 | + </div> | |
| 5 | + <div class="px-6 py-6"> | |
| 6 | + <div |
| --- a/templates/fossil/wiki_list.html | |
| +++ b/templates/fossil/wiki_list.html | |
| @@ -0,0 +1,6 @@ | |
| --- a/templates/fossil/wiki_list.html | |
| +++ b/templates/fossil/wiki_list.html | |
| @@ -0,0 +1,6 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% blotext-gray-400 hx-swap="innerHTML{ p.name }} |
| 3 | ged dela <h2 class="text-lg font-semibold text-gray-100">{{ home_page.name }}</h2> |
| 4 | </div> |
| 5 | <div class="px-6 py-6"> |
| 6 | <div |
| --- a/templates/fossil/wiki_page.html | ||
| +++ b/templates/fossil/wiki_page.html | ||
| @@ -0,0 +1,35 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% load fossil_filters %} | |
| 3 | +{% block title %}{{ page.name }} — Wiki — {{ project.name }} — Fossilrepo{% endblock %} | |
| 4 | + | |
| 5 | +{% block content %} | |
| 6 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> | |
| 7 | +{% include "fossil/_project_nav.html" %} | |
| 8 | + | |
| 9 | +<div class="flex gap-6"> | |
| 10 | + <!-- Main wiki content --> | |
| 11 | + <div class="flex-1 min-w-0"> | |
| 12 | + <div class="overflow-hidden roed-lg bg-gray-800 shadow-sm border border-gray-700"> | |
| 13 | + <div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between"> | |
| 14 | + <h2 class="text-lg font-semibold text-gray-100">{{ -center gap-3"> | |
| 15 | + <span class="text-xs text-gray-500">{{ page.last_modi</div> | |
| 16 | + <div class="px-6 py-6"> | |
| 17 | + <div class="prose prose-invert prose-gray max-w-none"> | |
| 18 | + {{ content_html }} | |
| 19 | + </div> | |
| 20 | + </div> | |
| 21 | + </div> | |
| 22 | + </div> | |
| 23 | + | |
| 24 | + <!-- Right sidebar: wiki page nav --> | |
| 25 | + <aside class="hidden lg:block w-52 flex-shrink-0"> | |
| 26 | + <div class="sticky top-6"> | |
| 27 | + <h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-3">Wiki Pages</h3> | |
| 28 | + <nav class="space-y-0.5"> | |
| 29 | + {% for p in all_pages %} | |
| 30 | + <a href="{% url 'fossil:wiki_page' slug=project.slug page_name=p.name %}" | |
| 31 | + class="block rounded-md px-3 py-1.5 text-sm {% if p.name == page.name %}bg-gray-800 text-brand-light font-medium border border-gray-700{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> | |
| 32 | + {{ p.name }} | |
| 33 | + </a> | |
| 34 | + {% endfor %} | |
| 35 | + |
| --- a/templates/fossil/wiki_page.html | |
| +++ b/templates/fossil/wiki_page.html | |
| @@ -0,0 +1,35 @@ | |
| --- a/templates/fossil/wiki_page.html | |
| +++ b/templates/fossil/wiki_page.html | |
| @@ -0,0 +1,35 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}{{ page.name }} — Wiki — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="flex gap-6"> |
| 10 | <!-- Main wiki content --> |
| 11 | <div class="flex-1 min-w-0"> |
| 12 | <div class="overflow-hidden roed-lg bg-gray-800 shadow-sm border border-gray-700"> |
| 13 | <div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between"> |
| 14 | <h2 class="text-lg font-semibold text-gray-100">{{ -center gap-3"> |
| 15 | <span class="text-xs text-gray-500">{{ page.last_modi</div> |
| 16 | <div class="px-6 py-6"> |
| 17 | <div class="prose prose-invert prose-gray max-w-none"> |
| 18 | {{ content_html }} |
| 19 | </div> |
| 20 | </div> |
| 21 | </div> |
| 22 | </div> |
| 23 | |
| 24 | <!-- Right sidebar: wiki page nav --> |
| 25 | <aside class="hidden lg:block w-52 flex-shrink-0"> |
| 26 | <div class="sticky top-6"> |
| 27 | <h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-3">Wiki Pages</h3> |
| 28 | <nav class="space-y-0.5"> |
| 29 | {% for p in all_pages %} |
| 30 | <a href="{% url 'fossil:wiki_page' slug=project.slug page_name=p.name %}" |
| 31 | class="block rounded-md px-3 py-1.5 text-sm {% if p.name == page.name %}bg-gray-800 text-brand-light font-medium border border-gray-700{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}"> |
| 32 | {{ p.name }} |
| 33 | </a> |
| 34 | {% endfor %} |
| 35 |
| --- templates/includes/nav.html | ||
| +++ templates/includes/nav.html | ||
| @@ -1,63 +1,37 @@ | ||
| 1 | 1 | {% load static %} |
| 2 | -<nav class="bg-gray-900 border-b border-gray-700" x-data="{ mobileOpen: false }"> | |
| 3 | - <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> | |
| 4 | - <div class="flex h-16 items-center justify-between"> | |
| 5 | - <div class="flex items-center"> | |
| 6 | - <a href="{% url 'dashboard' %}" class="flex-shrink-0"> | |
| 7 | - <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-8 w-auto"> | |
| 8 | - </a> | |
| 9 | - <div class="hidden md:block ml-10"> | |
| 10 | - <div class="flex items-baseline space-x-4"> | |
| 11 | - <a href="{% url 'dashboard' %}" | |
| 12 | - class="{% if request.path == '/dashboard/' %}bg-brand text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium"> | |
| 13 | - Dashboard | |
| 14 | - </a> | |
| 15 | - {% if perms.items.view_item %} | |
| 16 | - <a href="{% url 'items:list' %}" | |
| 17 | - class="{% if '/items/' in request.path %}bg-brand text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium"> | |
| 18 | - Items | |
| 19 | - </a> | |
| 20 | - {% endif %} | |
| 21 | - {% if user.is_staff %} | |
| 22 | - <a href="{% url 'admin:index' %}" | |
| 23 | - class="text-gray-400 hover:bg-gray-800 hover:text-white rounded-md px-3 py-2 text-sm font-medium"> | |
| 24 | - Admin | |
| 25 | - </a> | |
| 26 | - {% endif %} | |
| 27 | - </div> | |
| 28 | - </div> | |
| 29 | - </div> | |
| 30 | - <div class="hidden md:block"> | |
| 31 | - <div class="ml-4 flex items-center md:ml-6" x-data="{ open: false }"> | |
| 32 | - <div class="relative"> | |
| 33 | - <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white"> | |
| 34 | - {{ user.get_full_name|default:user.username }} | |
| 35 | - <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg> | |
| 36 | - </button> | |
| 37 | - <div x-show="open" @click.outside="open = false" x-transition | |
| 38 | - class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700"> | |
| 39 | - <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form> | |
| 40 | - </div> | |
| 41 | - </div> | |
| 42 | - </div> | |
| 43 | - </div> | |
| 44 | - <div class="md:hidden"> | |
| 45 | - <button @click="mobileOpen = !mobileOpen" class="text-gray-400 hover:text-white"> | |
| 46 | - <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 47 | - <path x-show="!mobileOpen" stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/> | |
| 48 | - <path x-show="mobileOpen" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> | |
| 49 | - </svg> | |
| 50 | - </button> | |
| 51 | - </div> | |
| 52 | - </div> | |
| 53 | - </div> | |
| 54 | - <div x-show="mobileOpen" class="md:hidden"> | |
| 55 | - <div class="space-y-1 px-2 pb-3 pt-2"> | |
| 56 | - <a href="{% url 'dashboard' %}" class="block rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Dashboard</a> | |
| 57 | - {% if perms.items.view_item %} | |
| 58 | - <a href="{% url 'items:list' %}" class="block rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Items</a> | |
| 59 | - {% endif %} | |
| 60 | - <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Sign out</button></form> | |
| 2 | +<nav class="bg-gray-900 border-b border-gray-700"> | |
| 3 | + <div class="px-4 sm:px-6 lg:px-8"> | |
| 4 | + <div class="flex h-14 items-center justify-between"> | |
| 5 | + <div class="flex items-center gap-3"> | |
| 6 | + <a href="{% url 'dashboard' %}" class="flex-shrink-0"> | |
| 7 | + <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-7 w-auto"> | |
| 8 | + </a> | |
| 9 | + </div> | |
| 10 | + <div class="flex items-center gap-3"> | |
| 11 | + <!-- Theme toggle --> | |
| 12 | + <button x-data="{ dark: document.documentElement.classList.contains('dark') }" | |
| 13 | + @click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', dark ? 'dark' : 'light')" | |
| 14 | + class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800" | |
| 15 | + title="Toggle theme"> | |
| 16 | + <svg x-show="dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 17 | + <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" /> | |
| 18 | + </svg> | |
| 19 | + <svg x-show="!dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none"> | |
| 20 | + <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" /> | |
| 21 | + </svg> | |
| 22 | + </button> | |
| 23 | + <!-- User menu --> | |
| 24 | + <div class="relative" x-data="{ open: false }"> | |
| 25 | + <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white"> | |
| 26 | + {{ user.get_full_name|default:user.username }} | |
| 27 | + <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg> | |
| 28 | + </button> | |
| 29 | + <div x-show="open" @click.outside="open = false" x-transition | |
| 30 | + class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700"> | |
| 31 | + <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form> | |
| 32 | + </div> | |
| 33 | + </div> | |
| 34 | + </div> | |
| 61 | 35 | </div> |
| 62 | 36 | </div> |
| 63 | 37 | </nav> |
| 64 | 38 | |
| 65 | 39 | ADDED templates/includes/sidebar.html |
| 66 | 40 | ADDED templates/organization/member_add.html |
| 67 | 41 | ADDED templates/organization/member_confirm_remove.html |
| 68 | 42 | ADDED templates/organization/member_list.html |
| 69 | 43 | ADDED templates/organization/partials/member_table.html |
| 70 | 44 | ADDED templates/organization/partials/team_member_table.html |
| 71 | 45 | ADDED templates/organization/partials/team_table.html |
| 72 | 46 | ADDED templates/organization/settings.html |
| 73 | 47 | ADDED templates/organization/settings_form.html |
| 74 | 48 | ADDED templates/organization/team_confirm_delete.html |
| 75 | 49 | ADDED templates/organization/team_detail.html |
| 76 | 50 | ADDED templates/organization/team_form.html |
| 77 | 51 | ADDED templates/organization/team_list.html |
| 78 | 52 | ADDED templates/organization/team_member_add.html |
| 79 | 53 | ADDED templates/organization/team_member_confirm_remove.html |
| 80 | 54 | ADDED templates/pages/page_confirm_delete.html |
| 81 | 55 | ADDED templates/pages/page_detail.html |
| 82 | 56 | ADDED templates/pages/page_form.html |
| 83 | 57 | ADDED templates/pages/page_list.html |
| 84 | 58 | ADDED templates/pages/partials/page_table.html |
| 85 | 59 | ADDED templates/projects/partials/project_table.html |
| 86 | 60 | ADDED templates/projects/partials/project_team_table.html |
| 87 | 61 | ADDED templates/projects/project_confirm_delete.html |
| 88 | 62 | ADDED templates/projects/project_detail.html |
| 89 | 63 | ADDED templates/projects/project_form.html |
| 90 | 64 | ADDED templates/projects/project_list.html |
| 91 | 65 | ADDED templates/projects/project_team_add.html |
| 92 | 66 | ADDED templates/projects/project_team_confirm_remove.html |
| 93 | 67 | ADDED templates/projects/project_team_edit.html |
| --- templates/includes/nav.html | |
| +++ templates/includes/nav.html | |
| @@ -1,63 +1,37 @@ | |
| 1 | {% load static %} |
| 2 | <nav class="bg-gray-900 border-b border-gray-700" x-data="{ mobileOpen: false }"> |
| 3 | <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> |
| 4 | <div class="flex h-16 items-center justify-between"> |
| 5 | <div class="flex items-center"> |
| 6 | <a href="{% url 'dashboard' %}" class="flex-shrink-0"> |
| 7 | <img src="{% static 'img/fossilrepo-logo-dark.svg' %}" alt="Fossilrepo" class="h-8 w-auto"> |
| 8 | </a> |
| 9 | <div class="hidden md:block ml-10"> |
| 10 | <div class="flex items-baseline space-x-4"> |
| 11 | <a href="{% url 'dashboard' %}" |
| 12 | class="{% if request.path == '/dashboard/' %}bg-brand text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium"> |
| 13 | Dashboard |
| 14 | </a> |
| 15 | {% if perms.items.view_item %} |
| 16 | <a href="{% url 'items:list' %}" |
| 17 | class="{% if '/items/' in request.path %}bg-brand text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %} rounded-md px-3 py-2 text-sm font-medium"> |
| 18 | Items |
| 19 | </a> |
| 20 | {% endif %} |
| 21 | {% if user.is_staff %} |
| 22 | <a href="{% url 'admin:index' %}" |
| 23 | class="text-gray-400 hover:bg-gray-800 hover:text-white rounded-md px-3 py-2 text-sm font-medium"> |
| 24 | Admin |
| 25 | </a> |
| 26 | {% endif %} |
| 27 | </div> |
| 28 | </div> |
| 29 | </div> |
| 30 | <div class="hidden md:block"> |
| 31 | <div class="ml-4 flex items-center md:ml-6" x-data="{ open: false }"> |
| 32 | <div class="relative"> |
| 33 | <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white"> |
| 34 | {{ user.get_full_name|default:user.username }} |
| 35 | <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg> |
| 36 | </button> |
| 37 | <div x-show="open" @click.outside="open = false" x-transition |
| 38 | class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700"> |
| 39 | <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form> |
| 40 | </div> |
| 41 | </div> |
| 42 | </div> |
| 43 | </div> |
| 44 | <div class="md:hidden"> |
| 45 | <button @click="mobileOpen = !mobileOpen" class="text-gray-400 hover:text-white"> |
| 46 | <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 47 | <path x-show="!mobileOpen" stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/> |
| 48 | <path x-show="mobileOpen" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> |
| 49 | </svg> |
| 50 | </button> |
| 51 | </div> |
| 52 | </div> |
| 53 | </div> |
| 54 | <div x-show="mobileOpen" class="md:hidden"> |
| 55 | <div class="space-y-1 px-2 pb-3 pt-2"> |
| 56 | <a href="{% url 'dashboard' %}" class="block rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Dashboard</a> |
| 57 | {% if perms.items.view_item %} |
| 58 | <a href="{% url 'items:list' %}" class="block rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Items</a> |
| 59 | {% endif %} |
| 60 | <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left rounded-md px-3 py-2 text-base font-medium text-gray-400 hover:bg-gray-800 hover:text-white">Sign out</button></form> |
| 61 | </div> |
| 62 | </div> |
| 63 | </nav> |
| 64 | |
| 65 | DDED templates/includes/sidebar.html |
| 66 | DDED templates/organization/member_add.html |
| 67 | DDED templates/organization/member_confirm_remove.html |
| 68 | DDED templates/organization/member_list.html |
| 69 | DDED templates/organization/partials/member_table.html |
| 70 | DDED templates/organization/partials/team_member_table.html |
| 71 | DDED templates/organization/partials/team_table.html |
| 72 | DDED templates/organization/settings.html |
| 73 | DDED templates/organization/settings_form.html |
| 74 | DDED templates/organization/team_confirm_delete.html |
| 75 | DDED templates/organization/team_detail.html |
| 76 | DDED templates/organization/team_form.html |
| 77 | DDED templates/organization/team_list.html |
| 78 | DDED templates/organization/team_member_add.html |
| 79 | DDED templates/organization/team_member_confirm_remove.html |
| 80 | DDED templates/pages/page_confirm_delete.html |
| 81 | DDED templates/pages/page_detail.html |
| 82 | DDED templates/pages/page_form.html |
| 83 | DDED templates/pages/page_list.html |
| 84 | DDED templates/pages/partials/page_table.html |
| 85 | DDED templates/projects/partials/project_table.html |
| 86 | DDED templates/projects/partials/project_team_table.html |
| 87 | DDED templates/projects/project_confirm_delete.html |
| 88 | DDED templates/projects/project_detail.html |
| 89 | DDED templates/projects/project_form.html |
| 90 | DDED templates/projects/project_list.html |
| 91 | DDED templates/projects/project_team_add.html |
| 92 | DDED templates/projects/project_team_confirm_remove.html |
| 93 | DDED templates/projects/project_team_edit.html |
| --- templates/includes/nav.html | |
| +++ templates/includes/nav.html | |
| @@ -1,63 +1,37 @@ | |
| 1 | {% load static %} |
| 2 | <nav class="bg-gray-900 border-b border-gray-700"> |
| 3 | <div class="px-4 sm:px-6 lg:px-8"> |
| 4 | <div class="flex h-14 items-center justify-between"> |
| 5 | <div class="flex items-center gap-3"> |
| 6 | <a href="{% url 'dashboard' %}" class="flex-shrink-0"> |
| 7 | <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-7 w-auto"> |
| 8 | </a> |
| 9 | </div> |
| 10 | <div class="flex items-center gap-3"> |
| 11 | <!-- Theme toggle --> |
| 12 | <button x-data="{ dark: document.documentElement.classList.contains('dark') }" |
| 13 | @click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', dark ? 'dark' : 'light')" |
| 14 | class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800" |
| 15 | title="Toggle theme"> |
| 16 | <svg x-show="dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 17 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" /> |
| 18 | </svg> |
| 19 | <svg x-show="!dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none"> |
| 20 | <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" /> |
| 21 | </svg> |
| 22 | </button> |
| 23 | <!-- User menu --> |
| 24 | <div class="relative" x-data="{ open: false }"> |
| 25 | <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white"> |
| 26 | {{ user.get_full_name|default:user.username }} |
| 27 | <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg> |
| 28 | </button> |
| 29 | <div x-show="open" @click.outside="open = false" x-transition |
| 30 | class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700"> |
| 31 | <form method="post" action="{% url 'auth1:logout' %}">{% csrf_token %}<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white">Sign out</button></form> |
| 32 | </div> |
| 33 | </div> |
| 34 | </div> |
| 35 | </div> |
| 36 | </div> |
| 37 | </nav> |
| 38 | |
| 39 | DDED templates/includes/sidebar.html |
| 40 | DDED templates/organization/member_add.html |
| 41 | DDED templates/organization/member_confirm_remove.html |
| 42 | DDED templates/organization/member_list.html |
| 43 | DDED templates/organization/partials/member_table.html |
| 44 | DDED templates/organization/partials/team_member_table.html |
| 45 | DDED templates/organization/partials/team_table.html |
| 46 | DDED templates/organization/settings.html |
| 47 | DDED templates/organization/settings_form.html |
| 48 | DDED templates/organization/team_confirm_delete.html |
| 49 | DDED templates/organization/team_detail.html |
| 50 | DDED templates/organization/team_form.html |
| 51 | DDED templates/organization/team_list.html |
| 52 | DDED templates/organization/team_member_add.html |
| 53 | DDED templates/organization/team_member_confirm_remove.html |
| 54 | DDED templates/pages/page_confirm_delete.html |
| 55 | DDED templates/pages/page_detail.html |
| 56 | DDED templates/pages/page_form.html |
| 57 | DDED templates/pages/page_list.html |
| 58 | DDED templates/pages/partials/page_table.html |
| 59 | DDED templates/projects/partials/project_table.html |
| 60 | DDED templates/projects/partials/project_team_table.html |
| 61 | DDED templates/projects/project_confirm_delete.html |
| 62 | DDED templates/projects/project_detail.html |
| 63 | DDED templates/projects/project_form.html |
| 64 | DDED templates/projects/project_list.html |
| 65 | DDED templates/projects/project_team_add.html |
| 66 | DDED templates/projects/project_team_confirm_remove.html |
| 67 | DDED templates/projects/project_team_edit.html |
| --- a/templates/includes/sidebar.html | ||
| +++ b/templates/includes/sidebar.html | ||
| @@ -0,0 +1,163 @@ | ||
| 1 | +<aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }" | |
| 2 | + :class="collapsed ? 'w-14' : 'w-60'" | |
| 3 | + class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200"> | |
| 4 | + <nav class="flex-1 px-2 py-4 space-y-1"> | |
| 5 | + | |
| 6 | + <!-- Collapse toggle --> | |
| 7 | + <button @click="collapsed = !collapsed; localStorage.setItem('sidebarCollapsed', collapsed)" | |
| 8 | + class="flex items-center justify-center w-full rounded-md px-2 py-2 text-gray-500 hover:text-white hover:bg-gray-800 mb-2" | |
| 9 | + :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"> | |
| 10 | + <svg x-show="!collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 11 | + <path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" /> | |
| 12 | + </svg> | |
| 13 | + <svg x-show="collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none"> | |
| 14 | + <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" /> | |
| 15 | + </svg> | |
| 16 | + </button> | |
| 17 | + | |
| 18 | + <!-- Dashboard --> | |
| 19 | + <a href="{% url 'dashboard' %}" | |
| 20 | + class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/dashboard/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 21 | + :title="collapsed ? 'Dashboard' : ''"> | |
| 22 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 23 | + <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> | |
| 24 | + </svg> | |
| 25 | + <span x-show="!collapsed" class="truncate">Dashboard</span> | |
| 26 | + </a> | |
| 27 | + | |
| 28 | + <!-- Projects section --> | |
| 29 | + {% if perms.projects.view_project %} | |
| 30 | + <div> | |
| 31 | + <button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)" | |
| 32 | + class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/projects/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 33 | + :title="collapsed ? 'Projects' : ''"> | |
| 34 | + <span class="flex items-center gap-2"> | |
| 35 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 36 | + <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" /> | |
| 37 | + </svg> | |
| 38 | + <span x-show="!collapsed" class="truncate">Projects</span> | |
| 39 | + </span> | |
| 40 | + <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 41 | + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> | |
| 42 | + </svg> | |
| 43 | + </button> | |
| 44 | + <div x-show="projectsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-2"> | |
| 45 | + {% for project in sidebar_projects %} | |
| 46 | + <div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }"> | |
| 47 | + <button @click="open = !open" | |
| 48 | + class="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm {% if project.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> | |
| 49 | + <span class="truncate">{{ project.name }}</span> | |
| 50 | + <svg class="h-3 w-3 flex-shrink-0 transition-transform" :class="open && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> | |
| 51 | + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> | |
| 52 | + </svg> | |
| 53 | + </button> | |
| 54 | + <div x-show="open" x-collapse class="ml-3 mt-0.5 space-y-0.5 border-l border-gray-700/50 pl-2"> | |
| 55 | + <a href="{% url 'projects:detail' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> | |
| 56 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg> | |
| 57 | + Overview | |
| 58 | + </a> | |
| 59 | + <a href="{% url 'fossil:code' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> | |
| 60 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg> | |
| 61 | + Code | |
| 62 | + </a> | |
| 63 | + <a href="{% url 'fossil:timeline' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> | |
| 64 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 65 | + Timeline | |
| 66 | + </a> | |
| 67 | + <a href="{% url 'fossil:tickets' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> | |
| 68 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg> | |
| 69 | + Tickets | |
| 70 | + </a> | |
| 71 | + <a href="{% url 'fossil:wiki' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> | |
| 72 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg> | |
| 73 | + Wiki | |
| 74 | + </a> | |
| 75 | + <a href="{% url 'fossil:forum' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> | |
| 76 | + <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg> | |
| 77 | + Forum | |
| 78 | + </a> | |
| 79 | + </div> | |
| 80 | + </div> | |
| 81 | + {% endfor %} | |
| 82 | + {% if perms.projects.add_project %} | |
| 83 | + <a href="{% url 'projects:create' %}" | |
| 84 | + class="block rounded-md px-2 py-1.5 text-sm text-gray-600 hover:text-brand-light"> | |
| 85 | + + New | |
| 86 | + </a> | |
| 87 | + {% endif %} | |
| 88 | + </div> | |
| 89 | + </div> | |
| 90 | + {% endif %} | |
| 91 | + | |
| 92 | + <!-- Teams --> | |
| 93 | + {% if perms.organization.view_team %} | |
| 94 | + <a href="{% url 'organization:team_list' %}" | |
| 95 | + class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/teams/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 96 | + :title="collapsed ? 'Teams' : ''"> | |
| 97 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 98 | + <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /> | |
| 99 | + </svg> | |
| 100 | + <span x-show="!collapsed" class="truncate">Teams</span> | |
| 101 | + </a> | |
| 102 | + {% endif %} | |
| 103 | + | |
| 104 | + <!-- Docs section --> | |
| 105 | + {% if perms.pages.view_page %} | |
| 106 | + <div> | |
| 107 | + <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" | |
| 108 | + class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/docs/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 109 | + :title="collapsed ? 'Docs' : ''"> | |
| 110 | + <span class="flex items-center gap-2"> | |
| 111 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 112 | + <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> | |
| 113 | + </svg> | |
| 114 | + <span x-show="!collapsed" class="truncate">Docs</span> | |
| 115 | + </span> | |
| 116 | + <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="docsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 117 | + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> | |
| 118 | + </svg> | |
| 119 | + </button> | |
| 120 | + <div x-show="docsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-3"> | |
| 121 | + {% for p in sidebar_pages %} | |
| 122 | + <a href="{% url 'pages:detail' slug=p.slug %}" | |
| 123 | + class="block rounded-md px-3 py-1.5 text-sm {% if p.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %} truncate"> | |
| 124 | + {{ p.name }} | |
| 125 | + </a> | |
| 126 | + {% endfor %} | |
| 127 | + {% if perms.pages.add_page %} | |
| 128 | + <a href="{% url 'pages:create' %}" | |
| 129 | + class="block rounded-md px-3 py-1.5 text-sm text-gray-600 hover:text-brand-light"> | |
| 130 | + + New | |
| 131 | + </a> | |
| 132 | + {% endif %} | |
| 133 | + </div> | |
| 134 | + </div> | |
| 135 | + {% endif %} | |
| 136 | + | |
| 137 | + <!-- Settings --> | |
| 138 | + {% if perms.organization.view_organization %} | |
| 139 | + <a href="{% url 'organization:settings' %}" | |
| 140 | + class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/' in request.path and '/settings/teams/' not in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 141 | + :title="collapsed ? 'Settings' : ''"> | |
| 142 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 143 | + <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /> | |
| 144 | + <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> | |
| 145 | + </svg> | |
| 146 | + <span x-show="!collapsed" class="truncate">Settings</span> | |
| 147 | + </a> | |
| 148 | + {% endif %} | |
| 149 | + | |
| 150 | + <!-- Admin --> | |
| 151 | + {% if user.is_staff %} | |
| 152 | + <a href="{% url 'admin:index' %}" | |
| 153 | + class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white" | |
| 154 | + :title="collapsed ? 'Admin' : ''"> | |
| 155 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 156 | + <path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.385 3.171a.75.75 0 01-1.089-.791l1.414-5.816-4.534-3.89a.75.75 0 01.422-1.317l5.953-.493 2.354-5.476a.75.75 0 011.39 0l2.354 5.476 5.953.493a.75.75 0 01.422 1.317l-4.534 3.89 1.414 5.816a.75.75 0 01-1.089.791L12 15.17l-.58.341z" /> | |
| 157 | + </svg> | |
| 158 | + <span x-show="!collapsed" class="truncate">Admin</span> | |
| 159 | + </a> | |
| 160 | + {% endif %} | |
| 161 | + | |
| 162 | + </nav> | |
| 163 | +</aside> |
| --- a/templates/includes/sidebar.html | |
| +++ b/templates/includes/sidebar.html | |
| @@ -0,0 +1,163 @@ | |
| --- a/templates/includes/sidebar.html | |
| +++ b/templates/includes/sidebar.html | |
| @@ -0,0 +1,163 @@ | |
| 1 | <aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }" |
| 2 | :class="collapsed ? 'w-14' : 'w-60'" |
| 3 | class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200"> |
| 4 | <nav class="flex-1 px-2 py-4 space-y-1"> |
| 5 | |
| 6 | <!-- Collapse toggle --> |
| 7 | <button @click="collapsed = !collapsed; localStorage.setItem('sidebarCollapsed', collapsed)" |
| 8 | class="flex items-center justify-center w-full rounded-md px-2 py-2 text-gray-500 hover:text-white hover:bg-gray-800 mb-2" |
| 9 | :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"> |
| 10 | <svg x-show="!collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 11 | <path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" /> |
| 12 | </svg> |
| 13 | <svg x-show="collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none"> |
| 14 | <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" /> |
| 15 | </svg> |
| 16 | </button> |
| 17 | |
| 18 | <!-- Dashboard --> |
| 19 | <a href="{% url 'dashboard' %}" |
| 20 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/dashboard/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 21 | :title="collapsed ? 'Dashboard' : ''"> |
| 22 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 23 | <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> |
| 24 | </svg> |
| 25 | <span x-show="!collapsed" class="truncate">Dashboard</span> |
| 26 | </a> |
| 27 | |
| 28 | <!-- Projects section --> |
| 29 | {% if perms.projects.view_project %} |
| 30 | <div> |
| 31 | <button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)" |
| 32 | class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/projects/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 33 | :title="collapsed ? 'Projects' : ''"> |
| 34 | <span class="flex items-center gap-2"> |
| 35 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 36 | <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" /> |
| 37 | </svg> |
| 38 | <span x-show="!collapsed" class="truncate">Projects</span> |
| 39 | </span> |
| 40 | <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 41 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 42 | </svg> |
| 43 | </button> |
| 44 | <div x-show="projectsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-2"> |
| 45 | {% for project in sidebar_projects %} |
| 46 | <div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }"> |
| 47 | <button @click="open = !open" |
| 48 | class="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm {% if project.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %}"> |
| 49 | <span class="truncate">{{ project.name }}</span> |
| 50 | <svg class="h-3 w-3 flex-shrink-0 transition-transform" :class="open && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"> |
| 51 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 52 | </svg> |
| 53 | </button> |
| 54 | <div x-show="open" x-collapse class="ml-3 mt-0.5 space-y-0.5 border-l border-gray-700/50 pl-2"> |
| 55 | <a href="{% url 'projects:detail' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> |
| 56 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg> |
| 57 | Overview |
| 58 | </a> |
| 59 | <a href="{% url 'fossil:code' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> |
| 60 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg> |
| 61 | Code |
| 62 | </a> |
| 63 | <a href="{% url 'fossil:timeline' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> |
| 64 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> |
| 65 | Timeline |
| 66 | </a> |
| 67 | <a href="{% url 'fossil:tickets' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> |
| 68 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg> |
| 69 | Tickets |
| 70 | </a> |
| 71 | <a href="{% url 'fossil:wiki' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> |
| 72 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg> |
| 73 | Wiki |
| 74 | </a> |
| 75 | <a href="{% url 'fossil:forum' slug=project.slug %}" class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-300"> |
| 76 | <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg> |
| 77 | Forum |
| 78 | </a> |
| 79 | </div> |
| 80 | </div> |
| 81 | {% endfor %} |
| 82 | {% if perms.projects.add_project %} |
| 83 | <a href="{% url 'projects:create' %}" |
| 84 | class="block rounded-md px-2 py-1.5 text-sm text-gray-600 hover:text-brand-light"> |
| 85 | + New |
| 86 | </a> |
| 87 | {% endif %} |
| 88 | </div> |
| 89 | </div> |
| 90 | {% endif %} |
| 91 | |
| 92 | <!-- Teams --> |
| 93 | {% if perms.organization.view_team %} |
| 94 | <a href="{% url 'organization:team_list' %}" |
| 95 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/teams/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 96 | :title="collapsed ? 'Teams' : ''"> |
| 97 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 98 | <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /> |
| 99 | </svg> |
| 100 | <span x-show="!collapsed" class="truncate">Teams</span> |
| 101 | </a> |
| 102 | {% endif %} |
| 103 | |
| 104 | <!-- Docs section --> |
| 105 | {% if perms.pages.view_page %} |
| 106 | <div> |
| 107 | <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" |
| 108 | class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/docs/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 109 | :title="collapsed ? 'Docs' : ''"> |
| 110 | <span class="flex items-center gap-2"> |
| 111 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 112 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> |
| 113 | </svg> |
| 114 | <span x-show="!collapsed" class="truncate">Docs</span> |
| 115 | </span> |
| 116 | <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="docsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 117 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 118 | </svg> |
| 119 | </button> |
| 120 | <div x-show="docsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-3"> |
| 121 | {% for p in sidebar_pages %} |
| 122 | <a href="{% url 'pages:detail' slug=p.slug %}" |
| 123 | class="block rounded-md px-3 py-1.5 text-sm {% if p.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %} truncate"> |
| 124 | {{ p.name }} |
| 125 | </a> |
| 126 | {% endfor %} |
| 127 | {% if perms.pages.add_page %} |
| 128 | <a href="{% url 'pages:create' %}" |
| 129 | class="block rounded-md px-3 py-1.5 text-sm text-gray-600 hover:text-brand-light"> |
| 130 | + New |
| 131 | </a> |
| 132 | {% endif %} |
| 133 | </div> |
| 134 | </div> |
| 135 | {% endif %} |
| 136 | |
| 137 | <!-- Settings --> |
| 138 | {% if perms.organization.view_organization %} |
| 139 | <a href="{% url 'organization:settings' %}" |
| 140 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/' in request.path and '/settings/teams/' not in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 141 | :title="collapsed ? 'Settings' : ''"> |
| 142 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 143 | <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /> |
| 144 | <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> |
| 145 | </svg> |
| 146 | <span x-show="!collapsed" class="truncate">Settings</span> |
| 147 | </a> |
| 148 | {% endif %} |
| 149 | |
| 150 | <!-- Admin --> |
| 151 | {% if user.is_staff %} |
| 152 | <a href="{% url 'admin:index' %}" |
| 153 | class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white" |
| 154 | :title="collapsed ? 'Admin' : ''"> |
| 155 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 156 | <path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.385 3.171a.75.75 0 01-1.089-.791l1.414-5.816-4.534-3.89a.75.75 0 01.422-1.317l5.953-.493 2.354-5.476a.75.75 0 011.39 0l2.354 5.476 5.953.493a.75.75 0 01.422 1.317l-4.534 3.89 1.414 5.816a.75.75 0 01-1.089.791L12 15.17l-.58.341z" /> |
| 157 | </svg> |
| 158 | <span x-show="!collapsed" class="truncate">Admin</span> |
| 159 | </a> |
| 160 | {% endif %} |
| 161 | |
| 162 | </nav> |
| 163 | </aside> |
| --- a/templates/organization/member_add.html | ||
| +++ b/templates/organization/member_add.html | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Add Member — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">← Back to Members</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-2xl"> | |
| 10 | + <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member</h1> | |
| 11 | + | |
| 12 | + <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 13 | + {% csrf_token %} | |
| 14 | + | |
| 15 | + {% for field in form %} | |
| 16 | + <div> | |
| 17 | + <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 18 | + {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 19 | + </label> | |
| 20 | + <div class="mt-1">{{ field }}</div> | |
| 21 | + {% if field.errors %} | |
| 22 | + <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 23 | + {% endif %} | |
| 24 | + </div> | |
| 25 | + {% endfor %} | |
| 26 | + | |
| 27 | + <div class="flex justify-end gap-3 pt-4"> | |
| 28 | + <a href="{% url 'organization:members' %}" | |
| 29 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 30 | + Cancel | |
| 31 | + </a> | |
| 32 | + <button type="submit" | |
| 33 | + class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 34 | + Add Member | |
| 35 | + </button> | |
| 36 | + </div> | |
| 37 | + </form> | |
| 38 | +</div> | |
| 39 | +{% endblock %} |
| --- a/templates/organization/member_add.html | |
| +++ b/templates/organization/member_add.html | |
| @@ -0,0 +1,39 @@ | |
| --- a/templates/organization/member_add.html | |
| +++ b/templates/organization/member_add.html | |
| @@ -0,0 +1,39 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Add Member — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">← Back to Members</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-2xl"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member</h1> |
| 11 | |
| 12 | <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 13 | {% csrf_token %} |
| 14 | |
| 15 | {% for field in form %} |
| 16 | <div> |
| 17 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 18 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 19 | </label> |
| 20 | <div class="mt-1">{{ field }}</div> |
| 21 | {% if field.errors %} |
| 22 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 23 | {% endif %} |
| 24 | </div> |
| 25 | {% endfor %} |
| 26 | |
| 27 | <div class="flex justify-end gap-3 pt-4"> |
| 28 | <a href="{% url 'organization:members' %}" |
| 29 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 30 | Cancel |
| 31 | </a> |
| 32 | <button type="submit" |
| 33 | class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 34 | Add Member |
| 35 | </button> |
| 36 | </div> |
| 37 | </form> |
| 38 | </div> |
| 39 | {% endblock %} |
| --- a/templates/organization/member_confirm_remove.html | ||
| +++ b/templates/organization/member_confirm_remove.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Remove Member — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">← Back to Members</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-lg"> | |
| 10 | + <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 11 | + <h2 class="text-lg font-semibold text-gray-100">Remove Member</h2> | |
| 12 | + <p class="mt-2 text-sm text-gray-400"> | |
| 13 | + Are you sure you want to remove <strong class="text-gray-100">{{ membership.member.username }}</strong> from the organization? This action uses soft delete — the membership can be recovered. | |
| 14 | + </p> | |
| 15 | + <form method="post" class="mt-6 flex justify-end gap-3"> | |
| 16 | + {% csrf_token %} | |
| 17 | + <a href="{% url 'organization:members' %}" | |
| 18 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 19 | + Cancel | |
| 20 | + </a> | |
| 21 | + <button type="submit" | |
| 22 | + class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 23 | + Remove | |
| 24 | + </button> | |
| 25 | + </form> | |
| 26 | + </div> | |
| 27 | +</div> | |
| 28 | +{% endblock %} |
| --- a/templates/organization/member_confirm_remove.html | |
| +++ b/templates/organization/member_confirm_remove.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/organization/member_confirm_remove.html | |
| +++ b/templates/organization/member_confirm_remove.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Remove Member — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">← Back to Members</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-lg"> |
| 10 | <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 11 | <h2 class="text-lg font-semibold text-gray-100">Remove Member</h2> |
| 12 | <p class="mt-2 text-sm text-gray-400"> |
| 13 | Are you sure you want to remove <strong class="text-gray-100">{{ membership.member.username }}</strong> from the organization? This action uses soft delete — the membership can be recovered. |
| 14 | </p> |
| 15 | <form method="post" class="mt-6 flex justify-end gap-3"> |
| 16 | {% csrf_token %} |
| 17 | <a href="{% url 'organization:members' %}" |
| 18 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 19 | Cancel |
| 20 | </a> |
| 21 | <button type="submit" |
| 22 | class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 23 | Remove |
| 24 | </button> |
| 25 | </form> |
| 26 | </div> |
| 27 | </div> |
| 28 | {% endblock %} |
| --- a/templates/organization/member_list.html | ||
| +++ b/templates/organization/member_list.html | ||
| @@ -0,0 +1,30 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Members — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="md:flex md:items-center md:justify-between mb-6"> | |
| 10 | + <h1 class="text-2xl font-bold tex{% if perms.organization.ar user.is_superuser %} | |
| 11 | + <a member_add' %}" | |
| 12 | + ay-100">Members</h1> | |
| 13 | +_create' %}" | |
| 14 | + class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semAdd Member | |
| 15 | + </a> | |
| 16 | + {% endif %} | |
| 17 | + <input type="search" | |
| 18 | + name="search" | |
| 19 | + value="{{ search }}" | |
| 20 | + placeholder="Search members..." | |
| 21 | + class="w-full max-w-mders" | |
| 22 | + class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 23 | + hx-get="{% url 'organization:members' %}" | |
| 24 | + hx-trigger="input changed delay:300ms, search" | |
| 25 | + hx-target="#member-table" | |
| 26 | + /> | |
| 27 | +</div> | |
| 28 | + | |
| 29 | +{% include "organization/partials/member_table.html" %} | |
| 30 | +{% include "includes/_paginatiendblock %} |
| --- a/templates/organization/member_list.html | |
| +++ b/templates/organization/member_list.html | |
| @@ -0,0 +1,30 @@ | |
| --- a/templates/organization/member_list.html | |
| +++ b/templates/organization/member_list.html | |
| @@ -0,0 +1,30 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Members — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 10 | <h1 class="text-2xl font-bold tex{% if perms.organization.ar user.is_superuser %} |
| 11 | <a member_add' %}" |
| 12 | ay-100">Members</h1> |
| 13 | _create' %}" |
| 14 | class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semAdd Member |
| 15 | </a> |
| 16 | {% endif %} |
| 17 | <input type="search" |
| 18 | name="search" |
| 19 | value="{{ search }}" |
| 20 | placeholder="Search members..." |
| 21 | class="w-full max-w-mders" |
| 22 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 23 | hx-get="{% url 'organization:members' %}" |
| 24 | hx-trigger="input changed delay:300ms, search" |
| 25 | hx-target="#member-table" |
| 26 | /> |
| 27 | </div> |
| 28 | |
| 29 | {% include "organization/partials/member_table.html" %} |
| 30 | {% include "includes/_paginatiendblock %} |
| --- a/templates/organization/partials/member_table.html | ||
| +++ b/templates/organization/partials/member_table.html | ||
| @@ -0,0 +1,18 @@ | ||
| 1 | +<div id="member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 2 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 3 | + <thead class="bg-gray-900">table"> | |
| 4 | + <div class="over<<div id="member-table"> | |
| 5 | + <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 6 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 7 | + <thead class="bg-gray-900/80"> | |
| 8 | + <tr> | |
| 9 | + <th class="px-6Status> | |
| 10 | + <th0">Username</th> | |
| 11 | + il</th> | |
| 12 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Role</th> | |
| 13 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Status</th> | |
| 14 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Joined</th> | |
| 15 | + text-gray-100"> | |
| 16 | + 5"s="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-400">Actions</th> | |
| 17 | + </tr> | |
| 18 | + |
| --- a/templates/organization/partials/member_table.html | |
| +++ b/templates/organization/partials/member_table.html | |
| @@ -0,0 +1,18 @@ | |
| --- a/templates/organization/partials/member_table.html | |
| +++ b/templates/organization/partials/member_table.html | |
| @@ -0,0 +1,18 @@ | |
| 1 | <div id="member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 2 | <table class="min-w-full divide-y divide-gray-700"> |
| 3 | <thead class="bg-gray-900">table"> |
| 4 | <div class="over<<div id="member-table"> |
| 5 | <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 6 | <table class="min-w-full divide-y divide-gray-700"> |
| 7 | <thead class="bg-gray-900/80"> |
| 8 | <tr> |
| 9 | <th class="px-6Status> |
| 10 | <th0">Username</th> |
| 11 | il</th> |
| 12 | <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Role</th> |
| 13 | <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Status</th> |
| 14 | <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Joined</th> |
| 15 | text-gray-100"> |
| 16 | 5"s="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-400">Actions</th> |
| 17 | </tr> |
| 18 |
| --- a/templates/organization/partials/team_member_table.html | ||
| +++ b/templates/organization/partials/team_member_table.html | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +<div id="team-member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 2 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 3 | + <thead class="bg-gray-900"> | |
| 4 | + <tr> | |
| 5 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Email</th> | |
| 6 | + <th class="px-6 py-3 text-right text bg-gray-800"> | |
| 7 | + {% for member in team_members %} | |
| 8 | + <tr class="hover:bg-gray-700/50"> | |
| 9 | + <td class="px-6 psm font-medium nowrap text-sm font-medium text-gray-100">{{ member.username }}</td> | |
| 10 | + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ member.email|default:"—" }}</td> | |
| 11 | + <td class="px-6 py-4 whitespace-nowrap text-right text-sm"> | |
| 12 | + {% if perms.organization.change_team %} | |
| 13 | + <a href="{% url 'organization:team_member_remove' slug=team.slug username=member.username %}" class="text-red-400 hover:text-red-300">Remove</a> | |
| 14 | + {% endif %} | |
| 15 | + </td> | |
| 16 | + </tr> | |
| 17 | + {% empty %} | |
| 18 | + <tr> | |
| 19 | + <td colspan="3" class="px-6 py-8 text-center text-sm text-gray-400">No members yet.</td> | |
| 20 | + </tr> | |
| 21 | + {% |
| --- a/templates/organization/partials/team_member_table.html | |
| +++ b/templates/organization/partials/team_member_table.html | |
| @@ -0,0 +1,21 @@ | |
| --- a/templates/organization/partials/team_member_table.html | |
| +++ b/templates/organization/partials/team_member_table.html | |
| @@ -0,0 +1,21 @@ | |
| 1 | <div id="team-member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 2 | <table class="min-w-full divide-y divide-gray-700"> |
| 3 | <thead class="bg-gray-900"> |
| 4 | <tr> |
| 5 | <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Email</th> |
| 6 | <th class="px-6 py-3 text-right text bg-gray-800"> |
| 7 | {% for member in team_members %} |
| 8 | <tr class="hover:bg-gray-700/50"> |
| 9 | <td class="px-6 psm font-medium nowrap text-sm font-medium text-gray-100">{{ member.username }}</td> |
| 10 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ member.email|default:"—" }}</td> |
| 11 | <td class="px-6 py-4 whitespace-nowrap text-right text-sm"> |
| 12 | {% if perms.organization.change_team %} |
| 13 | <a href="{% url 'organization:team_member_remove' slug=team.slug username=member.username %}" class="text-red-400 hover:text-red-300">Remove</a> |
| 14 | {% endif %} |
| 15 | </td> |
| 16 | </tr> |
| 17 | {% empty %} |
| 18 | <tr> |
| 19 | <td colspan="3" class="px-6 py-8 text-center text-sm text-gray-400">No members yet.</td> |
| 20 | </tr> |
| 21 | {% |
| --- a/templates/organization/partials/team_table.html | ||
| +++ b/templates/organization/partials/team_table.html | ||
| @@ -0,0 +1,26 @@ | ||
| 1 | +<div id="team-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 2 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 3 | + <thead class="bg-gray-900"> | |
| 4 | + <tr> | |
| 5 | + <th class="px-6 py-3 text-left text uppercase tracking-wider text-gray-400">Name</th> | |
| 6 | + <th class="px-6 py-3 text-left textext-gray-400">Members</th> | |
| 7 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800"> | |
| 8 | + {% for team in teams %} | |
| 9 | + <t50"> | |
| 10 | + <td class=""">Action <a href=detail' slug=team.slug %}" class="text-brand-light hover:text-brand font-medium"> | |
| 11 | + {{ team.name }} | |
| 12 | + </a> | |
| 13 | + </td> | |
| 14 | + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.members.count }}</td> | |
| 15 | + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.created_at|date:"N j, Y" }}</td> | |
| 16 | + <td class="px-6 py-4 whitespace-nowrap text-right text-sm"> | |
| 17 | + {% if perms.organization.change_team %} | |
| 18 | + <a href="{% url 'organization:team_update' slug=team.slug %}" class="text-brand-light hover:text-brand">Edit</a> | |
| 19 | + {% endif %} | |
| 20 | + </td> | |
| 21 | + </tr> | |
| 22 | + {% empty %} | |
| 23 | + <tr> | |
| 24 | + <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No teams found.</td> | |
| 25 | + </tr> | |
| 26 | + {% |
| --- a/templates/organization/partials/team_table.html | |
| +++ b/templates/organization/partials/team_table.html | |
| @@ -0,0 +1,26 @@ | |
| --- a/templates/organization/partials/team_table.html | |
| +++ b/templates/organization/partials/team_table.html | |
| @@ -0,0 +1,26 @@ | |
| 1 | <div id="team-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 2 | <table class="min-w-full divide-y divide-gray-700"> |
| 3 | <thead class="bg-gray-900"> |
| 4 | <tr> |
| 5 | <th class="px-6 py-3 text-left text uppercase tracking-wider text-gray-400">Name</th> |
| 6 | <th class="px-6 py-3 text-left textext-gray-400">Members</th> |
| 7 | <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800"> |
| 8 | {% for team in teams %} |
| 9 | <t50"> |
| 10 | <td class=""">Action <a href=detail' slug=team.slug %}" class="text-brand-light hover:text-brand font-medium"> |
| 11 | {{ team.name }} |
| 12 | </a> |
| 13 | </td> |
| 14 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.members.count }}</td> |
| 15 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.created_at|date:"N j, Y" }}</td> |
| 16 | <td class="px-6 py-4 whitespace-nowrap text-right text-sm"> |
| 17 | {% if perms.organization.change_team %} |
| 18 | <a href="{% url 'organization:team_update' slug=team.slug %}" class="text-brand-light hover:text-brand">Edit</a> |
| 19 | {% endif %} |
| 20 | </td> |
| 21 | </tr> |
| 22 | {% empty %} |
| 23 | <tr> |
| 24 | <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No teams found.</td> |
| 25 | </tr> |
| 26 | {% |
| --- a/templates/organization/settings.html | ||
| +++ b/templates/organization/settings.html | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Settings — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="md:flex md:items-center md:justify-between mb-6"> | |
| 6 | + <h1 class="text-2xl font-bold text-gray-100">Organization Settings</h1> | |
| 7 | + {% if perms.organization.change_organization %} | |
| 8 | + <a href="{% url 'organization:settings_edit' %}" | |
| 9 | + class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 10 | + Edit Settings | |
| 11 | + </a> | |
| 12 | + {% endif %} | |
| 13 | +</div> | |
| 14 | + | |
| 15 | +<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> | |
| 16 | + <div class="px-6 py-5"> | |
| 17 | + <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> | |
| 18 | + <div> | |
| 19 | + <dt class="text-sm font-medium text-gray-400">Name</dt> | |
| 20 | + <dd class="mt-1 text-sm text-gray-100">{{ org.name }}</dd> | |
| 21 | + </div> | |
| 22 | + <div> | |
| 23 | + <dt class="text-sm font-medium text-gray-400">Slug</dt> | |
| 24 | + <dd class="mt-1 text-sm text-gray-400 font-mono">{{ org.slug }}</dd> | |
| 25 | + </div> | |
| 26 | + <div> | |
| 27 | + <dt class="text-sm font-medium text-gray-400">Website</dt> | |
| 28 | + <dd class="mt-1 text-sm text-gray-100">{{ org.website|default:"—" }}</dd> | |
| 29 | + </div> | |
| 30 | + <div> | |
| 31 | + <dt class="text-sm font-medium text-gray-400">GUID</dt> | |
| 32 | + <dd class="mt-1 text-sm{% endblock %} |
| --- a/templates/organization/settings.html | |
| +++ b/templates/organization/settings.html | |
| @@ -0,0 +1,32 @@ | |
| --- a/templates/organization/settings.html | |
| +++ b/templates/organization/settings.html | |
| @@ -0,0 +1,32 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Settings — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 6 | <h1 class="text-2xl font-bold text-gray-100">Organization Settings</h1> |
| 7 | {% if perms.organization.change_organization %} |
| 8 | <a href="{% url 'organization:settings_edit' %}" |
| 9 | class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 10 | Edit Settings |
| 11 | </a> |
| 12 | {% endif %} |
| 13 | </div> |
| 14 | |
| 15 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 16 | <div class="px-6 py-5"> |
| 17 | <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> |
| 18 | <div> |
| 19 | <dt class="text-sm font-medium text-gray-400">Name</dt> |
| 20 | <dd class="mt-1 text-sm text-gray-100">{{ org.name }}</dd> |
| 21 | </div> |
| 22 | <div> |
| 23 | <dt class="text-sm font-medium text-gray-400">Slug</dt> |
| 24 | <dd class="mt-1 text-sm text-gray-400 font-mono">{{ org.slug }}</dd> |
| 25 | </div> |
| 26 | <div> |
| 27 | <dt class="text-sm font-medium text-gray-400">Website</dt> |
| 28 | <dd class="mt-1 text-sm text-gray-100">{{ org.website|default:"—" }}</dd> |
| 29 | </div> |
| 30 | <div> |
| 31 | <dt class="text-sm font-medium text-gray-400">GUID</dt> |
| 32 | <dd class="mt-1 text-sm{% endblock %} |
| --- a/templates/organization/settings_form.html | ||
| +++ b/templates/organization/settings_form.html | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Edit Settings — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-2xl"> | |
| 10 | + <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit Organization Settings</h1> | |
| 11 | + | |
| 12 | + <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 13 | + {% csrf_token %} | |
| 14 | + | |
| 15 | + {% for field in form %} | |
| 16 | + <div> | |
| 17 | + <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 18 | + {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 19 | + </label> | |
| 20 | + <div class="mt-1">{{ field }}</div> | |
| 21 | + {% if field.errors %} | |
| 22 | + <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 23 | + {% endif %} | |
| 24 | + </div> | |
| 25 | + {% endfor %} | |
| 26 | + | |
| 27 | + <div class="flex justify-end gap-3 pt-4"> | |
| 28 | + <a href="{% url 'organization:settings' %}" | |
| 29 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 30 | + Cancel | |
| 31 | + </a> | |
| 32 | + <button type="submit" | |
| 33 | + class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 34 | + Save | |
| 35 | + </button> | |
| 36 | + </div> | |
| 37 | + </form> | |
| 38 | +</div> | |
| 39 | +{% endblock %} |
| --- a/templates/organization/settings_form.html | |
| +++ b/templates/organization/settings_form.html | |
| @@ -0,0 +1,39 @@ | |
| --- a/templates/organization/settings_form.html | |
| +++ b/templates/organization/settings_form.html | |
| @@ -0,0 +1,39 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Edit Settings — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-2xl"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit Organization Settings</h1> |
| 11 | |
| 12 | <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 13 | {% csrf_token %} |
| 14 | |
| 15 | {% for field in form %} |
| 16 | <div> |
| 17 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 18 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 19 | </label> |
| 20 | <div class="mt-1">{{ field }}</div> |
| 21 | {% if field.errors %} |
| 22 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 23 | {% endif %} |
| 24 | </div> |
| 25 | {% endfor %} |
| 26 | |
| 27 | <div class="flex justify-end gap-3 pt-4"> |
| 28 | <a href="{% url 'organization:settings' %}" |
| 29 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 30 | Cancel |
| 31 | </a> |
| 32 | <button type="submit" |
| 33 | class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 34 | Save |
| 35 | </button> |
| 36 | </div> |
| 37 | </form> |
| 38 | </div> |
| 39 | {% endblock %} |
| --- a/templates/organization/team_confirm_delete.html | ||
| +++ b/templates/organization/team_confirm_delete.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Delete {{ team.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ team.name }}</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-lg"> | |
| 10 | + <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 11 | + <h2 class="text-lg font-semibold text-gray-100">Delete Team</h2> | |
| 12 | + <p class="mt-2 text-sm text-gray-400"> | |
| 13 | + Are you sure you want to delete <strong class="text-gray-100">{{ team.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered. | |
| 14 | + </p> | |
| 15 | + <form method="post" class="mt-6 flex justify-end gap-3"> | |
| 16 | + {% csrf_token %} | |
| 17 | + <a href="{% url 'organization:team_detail' slug=team.slug %}" | |
| 18 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 19 | + Cancel | |
| 20 | + </a> | |
| 21 | + <button type="submit" | |
| 22 | + class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 23 | + Delete | |
| 24 | + </button> | |
| 25 | + </form> | |
| 26 | + </div> | |
| 27 | +</div> | |
| 28 | +{% endblock %} |
| --- a/templates/organization/team_confirm_delete.html | |
| +++ b/templates/organization/team_confirm_delete.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/organization/team_confirm_delete.html | |
| +++ b/templates/organization/team_confirm_delete.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Delete {{ team.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ team.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-lg"> |
| 10 | <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 11 | <h2 class="text-lg font-semibold text-gray-100">Delete Team</h2> |
| 12 | <p class="mt-2 text-sm text-gray-400"> |
| 13 | Are you sure you want to delete <strong class="text-gray-100">{{ team.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered. |
| 14 | </p> |
| 15 | <form method="post" class="mt-6 flex justify-end gap-3"> |
| 16 | {% csrf_token %} |
| 17 | <a href="{% url 'organization:team_detail' slug=team.slug %}" |
| 18 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 19 | Cancel |
| 20 | </a> |
| 21 | <button type="submit" |
| 22 | class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 23 | Delete |
| 24 | </button> |
| 25 | </form> |
| 26 | </div> |
| 27 | </div> |
| 28 | {% endblock %} |
| --- a/templates/organization/team_detail.html | ||
| +++ b/templates/organization/team_detail.html | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ team.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Teams</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> | |
| 10 | + <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between"> | |
| 11 | + <div> | |
| 12 | + <h1 class="text-2xl font-bold text-gray-100">{{ team.name }}</h1> | |
| 13 | + <p class="mt-1 text-sm text-gray-400">{{ team.slug }}</p> | |
| 14 | + </div> | |
| 15 | + <div class="mt-4 flex gap-3 sm:mt-0"> | |
| 16 | + {% if perms.organization.change_team %} | |
| 17 | + <a href="{% url 'organization:team_update' slug=team.slug %}" | |
| 18 | + class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 19 | + Edit | |
| 20 | + </a> | |
| 21 | + {% endif %} | |
| 22 | + {% if perms.organization.delete_team %} | |
| 23 | + <a href="{% url 'organization:team_delete' slug=team.slug %}" | |
| 24 | + class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 25 | + Delete | |
| 26 | + </a> | |
| 27 | + {% endif %} | |
| 28 | + </div> | |
| 29 | + </div> | |
| 30 | + | |
| 31 | + <div class="border-t border-gray-700 px-6 py-5"> | |
| 32 | + <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> | |
| 33 | + <div class="sm:col-span-2"> | |
| 34 | + <dt class="text-sm font-medium text-gray-400">Description</dt> | |
| 35 | + <dd class="mt-1 text-sm text-gray-100">{{ team.description|default:"No description." }}</dd> | |
| 36 | + </div> | |
| 37 | + <div> | |
| 38 | + <dt class="text-sm font-medium text-gray-400">GUID</dt> | |
| 39 | + <dd class="mt-1 text-sm text-gray-400 font-mono">{{ team.guid }}</dd> | |
| 40 | + </div> | |
| 41 | + <div> | |
| 42 | + <dt class="text-sm font-medium text-gray-400">Created</dt> | |
| 43 | + <dd class="mt-1 text-sm text-gray-400">{{ team.created_at|date:"N j, Y g:i a" }} by {{ team.created_by|default:"system" }}</dd> | |
| 44 | + </div> | |
| 45 | + </dl> | |
| 46 | + </div> | |
| 47 | +</div> | |
| 48 | + | |
| 49 | +<div class="mt-8"> | |
| 50 | + <div class="md:flex md:items-center md:justify-between mb-4"> | |
| 51 | + <h2 class="text-lg font-semibold text-gray-100">Team Members</h2> | |
| 52 | + {% if perms.organization.change_team %} | |
| 53 | + <a href="{% url 'organization:team_member_add' slug=team.slug %}" | |
| 54 | + class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 55 | + Add Member | |
| 56 | + </a> | |
| 57 | + {% endif %} | |
| 58 | + </div> | |
| 59 | + | |
| 60 | + {% include "organization/partials/team_member_table.html" %} | |
| 61 | +</div> | |
| 62 | +{% endblock %} |
| --- a/templates/organization/team_detail.html | |
| +++ b/templates/organization/team_detail.html | |
| @@ -0,0 +1,62 @@ | |
| --- a/templates/organization/team_detail.html | |
| +++ b/templates/organization/team_detail.html | |
| @@ -0,0 +1,62 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ team.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Teams</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 10 | <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between"> |
| 11 | <div> |
| 12 | <h1 class="text-2xl font-bold text-gray-100">{{ team.name }}</h1> |
| 13 | <p class="mt-1 text-sm text-gray-400">{{ team.slug }}</p> |
| 14 | </div> |
| 15 | <div class="mt-4 flex gap-3 sm:mt-0"> |
| 16 | {% if perms.organization.change_team %} |
| 17 | <a href="{% url 'organization:team_update' slug=team.slug %}" |
| 18 | class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 19 | Edit |
| 20 | </a> |
| 21 | {% endif %} |
| 22 | {% if perms.organization.delete_team %} |
| 23 | <a href="{% url 'organization:team_delete' slug=team.slug %}" |
| 24 | class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 25 | Delete |
| 26 | </a> |
| 27 | {% endif %} |
| 28 | </div> |
| 29 | </div> |
| 30 | |
| 31 | <div class="border-t border-gray-700 px-6 py-5"> |
| 32 | <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> |
| 33 | <div class="sm:col-span-2"> |
| 34 | <dt class="text-sm font-medium text-gray-400">Description</dt> |
| 35 | <dd class="mt-1 text-sm text-gray-100">{{ team.description|default:"No description." }}</dd> |
| 36 | </div> |
| 37 | <div> |
| 38 | <dt class="text-sm font-medium text-gray-400">GUID</dt> |
| 39 | <dd class="mt-1 text-sm text-gray-400 font-mono">{{ team.guid }}</dd> |
| 40 | </div> |
| 41 | <div> |
| 42 | <dt class="text-sm font-medium text-gray-400">Created</dt> |
| 43 | <dd class="mt-1 text-sm text-gray-400">{{ team.created_at|date:"N j, Y g:i a" }} by {{ team.created_by|default:"system" }}</dd> |
| 44 | </div> |
| 45 | </dl> |
| 46 | </div> |
| 47 | </div> |
| 48 | |
| 49 | <div class="mt-8"> |
| 50 | <div class="md:flex md:items-center md:justify-between mb-4"> |
| 51 | <h2 class="text-lg font-semibold text-gray-100">Team Members</h2> |
| 52 | {% if perms.organization.change_team %} |
| 53 | <a href="{% url 'organization:team_member_add' slug=team.slug %}" |
| 54 | class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 55 | Add Member |
| 56 | </a> |
| 57 | {% endif %} |
| 58 | </div> |
| 59 | |
| 60 | {% include "organization/partials/team_member_table.html" %} |
| 61 | </div> |
| 62 | {% endblock %} |
| --- a/templates/organization/team_form.html | ||
| +++ b/templates/organization/team_form.html | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ title }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Teams</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-2xl"> | |
| 10 | + <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> | |
| 11 | + | |
| 12 | + <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 13 | + {% csrf_token %} | |
| 14 | + | |
| 15 | + {% for field in form %} | |
| 16 | + <div> | |
| 17 | + <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 18 | + {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 19 | + </label> | |
| 20 | + <div class="mt-1">{{ field }}</div> | |
| 21 | + {% if field.errors %} | |
| 22 | + <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 23 | + {% endif %} | |
| 24 | + </div> | |
| 25 | + {% endfor %} | |
| 26 | + | |
| 27 | + <div class="flex justify-end gap-3 pt-4"> | |
| 28 | + <a href="{% url 'organization:team_list' %}" | |
| 29 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 30 | + Cancel | |
| 31 | + </a> | |
| 32 | + <button type="submit" | |
| 33 | + class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 34 | + {% if team %}Update{% else %}Create{% endif %} | |
| 35 | + </button> | |
| 36 | + </div> | |
| 37 | + </form> | |
| 38 | +</div> | |
| 39 | +{% endblock %} |
| --- a/templates/organization/team_form.html | |
| +++ b/templates/organization/team_form.html | |
| @@ -0,0 +1,39 @@ | |
| --- a/templates/organization/team_form.html | |
| +++ b/templates/organization/team_form.html | |
| @@ -0,0 +1,39 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ title }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Teams</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-2xl"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> |
| 11 | |
| 12 | <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 13 | {% csrf_token %} |
| 14 | |
| 15 | {% for field in form %} |
| 16 | <div> |
| 17 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 18 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 19 | </label> |
| 20 | <div class="mt-1">{{ field }}</div> |
| 21 | {% if field.errors %} |
| 22 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 23 | {% endif %} |
| 24 | </div> |
| 25 | {% endfor %} |
| 26 | |
| 27 | <div class="flex justify-end gap-3 pt-4"> |
| 28 | <a href="{% url 'organization:team_list' %}" |
| 29 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 30 | Cancel |
| 31 | </a> |
| 32 | <button type="submit" |
| 33 | class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 34 | {% if team %}Update{% else %}Create{% endif %} |
| 35 | </button> |
| 36 | </div> |
| 37 | </form> |
| 38 | </div> |
| 39 | {% endblock %} |
| --- a/templates/organization/team_list.html | ||
| +++ b/templates/organization/team_list.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Teams — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="md:flex md:items-center md:justify-between mb-6"> | |
| 10 | + <h1 class="text-2xl font-bold text-gray-100">Teams</h1> | |
| 11 | + {% if perms.organization.add_team %} | |
| 12 | + <a href="{% url 'organization:team_create' %}" | |
| 13 | + class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 14 | + New Team | |
| 15 | + </a> | |
| 16 | + {% endif %} | |
| 17 | +</div> | |
| 18 | + | |
| 19 | +<div class="mb-4class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 20 | + hx-get="{% url 'organization:team_list' %}" | |
| 21 | + hx-trigger="input changed delay:300ms, search" | |
| 22 | + hx-target="#team-table" | |
| 23 | + hx-swap="outerHTML" | |
| 24 | + /> | |
| 25 | +</div> | |
| 26 | + | |
| 27 | +{% include "organization/partials/team_table.html" %} | |
| 28 | +{% include "includes/_paginatiendblock %} |
| --- a/templates/organization/team_list.html | |
| +++ b/templates/organization/team_list.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/organization/team_list.html | |
| +++ b/templates/organization/team_list.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Teams — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">← Back to Settings</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100">Teams</h1> |
| 11 | {% if perms.organization.add_team %} |
| 12 | <a href="{% url 'organization:team_create' %}" |
| 13 | class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 14 | New Team |
| 15 | </a> |
| 16 | {% endif %} |
| 17 | </div> |
| 18 | |
| 19 | <div class="mb-4class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 20 | hx-get="{% url 'organization:team_list' %}" |
| 21 | hx-trigger="input changed delay:300ms, search" |
| 22 | hx-target="#team-table" |
| 23 | hx-swap="outerHTML" |
| 24 | /> |
| 25 | </div> |
| 26 | |
| 27 | {% include "organization/partials/team_table.html" %} |
| 28 | {% include "includes/_paginatiendblock %} |
| --- a/templates/organization/team_member_add.html | ||
| +++ b/templates/organization/team_member_add.html | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Add Member to {{ team.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ team.name }}</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-2xl"> | |
| 10 | + <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member to {{ team.name }}</h1> | |
| 11 | + | |
| 12 | + <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 13 | + {% csrf_token %} | |
| 14 | + | |
| 15 | + {% for field in form %} | |
| 16 | + <div> | |
| 17 | + <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 18 | + {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 19 | + </label> | |
| 20 | + <div class="mt-1">{{ field }}</div> | |
| 21 | + {% if field.errors %} | |
| 22 | + <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 23 | + {% endif %} | |
| 24 | + </div> | |
| 25 | + {% endfor %} | |
| 26 | + | |
| 27 | + <div class="flex justify-end gap-3 pt-4"> | |
| 28 | + <a href="{% url 'organization:team_detail' slug=team.slug %}" | |
| 29 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 30 | + Cancel | |
| 31 | + </a> | |
| 32 | + <button type="submit" | |
| 33 | + class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 34 | + Add Member | |
| 35 | + </button> | |
| 36 | + </div> | |
| 37 | + </form> | |
| 38 | +</div> | |
| 39 | +{% endblock %} |
| --- a/templates/organization/team_member_add.html | |
| +++ b/templates/organization/team_member_add.html | |
| @@ -0,0 +1,39 @@ | |
| --- a/templates/organization/team_member_add.html | |
| +++ b/templates/organization/team_member_add.html | |
| @@ -0,0 +1,39 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Add Member to {{ team.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ team.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-2xl"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member to {{ team.name }}</h1> |
| 11 | |
| 12 | <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 13 | {% csrf_token %} |
| 14 | |
| 15 | {% for field in form %} |
| 16 | <div> |
| 17 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 18 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 19 | </label> |
| 20 | <div class="mt-1">{{ field }}</div> |
| 21 | {% if field.errors %} |
| 22 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 23 | {% endif %} |
| 24 | </div> |
| 25 | {% endfor %} |
| 26 | |
| 27 | <div class="flex justify-end gap-3 pt-4"> |
| 28 | <a href="{% url 'organization:team_detail' slug=team.slug %}" |
| 29 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 30 | Cancel |
| 31 | </a> |
| 32 | <button type="submit" |
| 33 | class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 34 | Add Member |
| 35 | </button> |
| 36 | </div> |
| 37 | </form> |
| 38 | </div> |
| 39 | {% endblock %} |
| --- a/templates/organization/team_member_confirm_remove.html | ||
| +++ b/templates/organization/team_member_confirm_remove.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Remove Member — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ team.name }}</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-lg"> | |
| 10 | + <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 11 | + <h2 class="text-lg font-semibold text-gray-100">Remove Member</h2> | |
| 12 | + <p class="mt-2 text-sm text-gray-400"> | |
| 13 | + Are you sure you want to remove <strong class="text-gray-100">{{ member_user.username }}</strong> from <strong class="text-gray-100">{{ team.name }}</strong>? | |
| 14 | + </p> | |
| 15 | + <form method="post" class="mt-6 flex justify-end gap-3"> | |
| 16 | + {% csrf_token %} | |
| 17 | + <a href="{% url 'organization:team_detail' slug=team.slug %}" | |
| 18 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 19 | + Cancel | |
| 20 | + </a> | |
| 21 | + <button type="submit" | |
| 22 | + class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 23 | + Remove | |
| 24 | + </button> | |
| 25 | + </form> | |
| 26 | + </div> | |
| 27 | +</div> | |
| 28 | +{% endblock %} |
| --- a/templates/organization/team_member_confirm_remove.html | |
| +++ b/templates/organization/team_member_confirm_remove.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/organization/team_member_confirm_remove.html | |
| +++ b/templates/organization/team_member_confirm_remove.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Remove Member — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ team.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-lg"> |
| 10 | <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 11 | <h2 class="text-lg font-semibold text-gray-100">Remove Member</h2> |
| 12 | <p class="mt-2 text-sm text-gray-400"> |
| 13 | Are you sure you want to remove <strong class="text-gray-100">{{ member_user.username }}</strong> from <strong class="text-gray-100">{{ team.name }}</strong>? |
| 14 | </p> |
| 15 | <form method="post" class="mt-6 flex justify-end gap-3"> |
| 16 | {% csrf_token %} |
| 17 | <a href="{% url 'organization:team_detail' slug=team.slug %}" |
| 18 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 19 | Cancel |
| 20 | </a> |
| 21 | <button type="submit" |
| 22 | class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 23 | Remove |
| 24 | </button> |
| 25 | </form> |
| 26 | </div> |
| 27 | </div> |
| 28 | {% endblock %} |
| --- a/templates/pages/page_confirm_delete.html | ||
| +++ b/templates/pages/page_confirm_delete.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Delete {{ page.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'pages:detail' slug=page.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ page.name }}</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-lg"> | |
| 10 | + <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 11 | + <h2 class="text-lg font-semibold text-gray-100">Delete Page</h2> | |
| 12 | + <p class="mt-2 text-sm text-gray-400"> | |
| 13 | + Are you sure you want to delete <strong class="text-gray-100">{{ page.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered. | |
| 14 | + </p> | |
| 15 | + <form method="post" class="mt-6 flex justify-end gap-3"> | |
| 16 | + {% csrf_token %} | |
| 17 | + <a href="{% url 'pages:detail' slug=page.slug %}" | |
| 18 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 19 | + Cancel | |
| 20 | + </a> | |
| 21 | + <button type="submit" | |
| 22 | + class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 23 | + Delete | |
| 24 | + </button> | |
| 25 | + </form> | |
| 26 | + </div> | |
| 27 | +</div> | |
| 28 | +{% endblock %} |
| --- a/templates/pages/page_confirm_delete.html | |
| +++ b/templates/pages/page_confirm_delete.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/pages/page_confirm_delete.html | |
| +++ b/templates/pages/page_confirm_delete.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Delete {{ page.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'pages:detail' slug=page.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ page.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-lg"> |
| 10 | <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 11 | <h2 class="text-lg font-semibold text-gray-100">Delete Page</h2> |
| 12 | <p class="mt-2 text-sm text-gray-400"> |
| 13 | Are you sure you want to delete <strong class="text-gray-100">{{ page.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered. |
| 14 | </p> |
| 15 | <form method="post" class="mt-6 flex justify-end gap-3"> |
| 16 | {% csrf_token %} |
| 17 | <a href="{% url 'pages:detail' slug=page.slug %}" |
| 18 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 19 | Cancel |
| 20 | </a> |
| 21 | <button type="submit" |
| 22 | class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 23 | Delete |
| 24 | </button> |
| 25 | </form> |
| 26 | </div> |
| 27 | </div> |
| 28 | {% endblock %} |
| --- a/templates/pages/page_detail.html | ||
| +++ b/templates/pages/page_detail.html | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ page.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> | |
| 6 | + <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between border-b border-gray-700"> | |
| 7 | + <div> | |
| 8 | + <h1 class="text-2xl font-bold text-gray-100">{{ page.name }}</h1> | |
| 9 | + <p class="mt-1 text-sm text-gray-400"> | |
| 10 | + {% if not page.is_published %} | |
| 11 | + <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300 mr-2">Draft</span> | |
| 12 | + {% endif %} | |
| 13 | + Updated {{ page.updated_at|date:"N j, Y g:i a" }}{% if page.updated_by %} by {{ page.updated_by }}{% endif %} | |
| 14 | + </p> | |
| 15 | + </div> | |
| 16 | + <div class="mt-4 flex gap-3 sm:mt-0"> | |
| 17 | + {% if perms.pages.change_page %} | |
| 18 | + <a href="{% url 'pages:update' slug=page.slug %}" | |
| 19 | + class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 20 | + Edit | |
| 21 | + </a> | |
| 22 | + {% endif %} | |
| 23 | + {% if perms.pages.delete_page %} | |
| 24 | + <a href="{% url 'pages:delete' slug=page.slug %}" | |
| 25 | + class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 26 | + Delete | |
| 27 | + </a> | |
| 28 | + {% endif %} | |
| 29 | + </div> | |
| 30 | + </div> | |
| 31 | + | |
| 32 | + <div class="px-6 py-6"> | |
| 33 | + <div class="prose prose-invert prose-gray max-w-none"> | |
| 34 | + {{ content_html }} | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | +</div> | |
| 38 | +{% endblock %} |
| --- a/templates/pages/page_detail.html | |
| +++ b/templates/pages/page_detail.html | |
| @@ -0,0 +1,38 @@ | |
| --- a/templates/pages/page_detail.html | |
| +++ b/templates/pages/page_detail.html | |
| @@ -0,0 +1,38 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ page.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 6 | <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between border-b border-gray-700"> |
| 7 | <div> |
| 8 | <h1 class="text-2xl font-bold text-gray-100">{{ page.name }}</h1> |
| 9 | <p class="mt-1 text-sm text-gray-400"> |
| 10 | {% if not page.is_published %} |
| 11 | <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300 mr-2">Draft</span> |
| 12 | {% endif %} |
| 13 | Updated {{ page.updated_at|date:"N j, Y g:i a" }}{% if page.updated_by %} by {{ page.updated_by }}{% endif %} |
| 14 | </p> |
| 15 | </div> |
| 16 | <div class="mt-4 flex gap-3 sm:mt-0"> |
| 17 | {% if perms.pages.change_page %} |
| 18 | <a href="{% url 'pages:update' slug=page.slug %}" |
| 19 | class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 20 | Edit |
| 21 | </a> |
| 22 | {% endif %} |
| 23 | {% if perms.pages.delete_page %} |
| 24 | <a href="{% url 'pages:delete' slug=page.slug %}" |
| 25 | class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 26 | Delete |
| 27 | </a> |
| 28 | {% endif %} |
| 29 | </div> |
| 30 | </div> |
| 31 | |
| 32 | <div class="px-6 py-6"> |
| 33 | <div class="prose prose-invert prose-gray max-w-none"> |
| 34 | {{ content_html }} |
| 35 | </div> |
| 36 | </div> |
| 37 | </div> |
| 38 | {% endblock %} |
| --- a/templates/pages/page_form.html | ||
| +++ b/templates/pages/page_form.html | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ title }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand"KBrr; Back to FoDocsr; Back to FoKnowledge Baserr; Back to FossilRepo Docs</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-4xl"> | |
| 10 | + <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> | |
| 11 | + | |
| 12 | + <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 13 | + {% csrf_token %} | |
| 14 | + | |
| 15 | + {% for field in form %} | |
| 16 | + <div> | |
| 17 | + <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 18 | + {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 19 | + </label> | |
| 20 | + <div class="mt-1">{{ field }}</div> | |
| 21 | + {% if field.errors %} | |
| 22 | + <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 23 | + {% endif %} | |
| 24 | + {% if field.help_text %} | |
| 25 | + <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p> | |
| 26 | + {% endif %} | |
| 27 | + </div> | |
| 28 | + {% endfor %} | |
| 29 | + | |
| 30 | + <div class="flex justify-end gap-3 pt-4"> | |
| 31 | + <a href="{% url 'pages:list' %}" | |
| 32 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 33 | + Cancel | |
| 34 | + </a> | |
| 35 | + <button type="submit" | |
| 36 | + class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 37 | + {% if page %}Update{% els |
| --- a/templates/pages/page_form.html | |
| +++ b/templates/pages/page_form.html | |
| @@ -0,0 +1,37 @@ | |
| --- a/templates/pages/page_form.html | |
| +++ b/templates/pages/page_form.html | |
| @@ -0,0 +1,37 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ title }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand"KBrr; Back to FoDocsr; Back to FoKnowledge Baserr; Back to FossilRepo Docs</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-4xl"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> |
| 11 | |
| 12 | <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 13 | {% csrf_token %} |
| 14 | |
| 15 | {% for field in form %} |
| 16 | <div> |
| 17 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 18 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 19 | </label> |
| 20 | <div class="mt-1">{{ field }}</div> |
| 21 | {% if field.errors %} |
| 22 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 23 | {% endif %} |
| 24 | {% if field.help_text %} |
| 25 | <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p> |
| 26 | {% endif %} |
| 27 | </div> |
| 28 | {% endfor %} |
| 29 | |
| 30 | <div class="flex justify-end gap-3 pt-4"> |
| 31 | <a href="{% url 'pages:list' %}" |
| 32 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 33 | Cancel |
| 34 | </a> |
| 35 | <button type="submit" |
| 36 | class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 37 | {% if page %}Update{% els |
| --- a/templates/pages/page_list.html | ||
| +++ b/templates/pages/page_list.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "baseDocsock title %}FossilRepo Docs — Fossilrepo{% endblock %} | |
| 2 | + | |
| 3 | +{% block content %} | |
| 4 | +<div class="md:flex md:items-center md:justify-between mb-6"> | |
| 5 | + <hKBt-gray-100">FoDocs</h1> | |
| 6 | + {% if perms.pages.add_page %} | |
| 7 | + <a href="{% url 'pages:create' %}" | |
| 8 | + class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 9 | + New Page | |
| 10 | + </a> | |
| 11 | + {% endif %} | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="mb-4"> | |
| 15 | + <input type="search" | |
| 16 | + name="search" | |
| 17 | + value="{{ search }}" | |
| 18 | + placeholder="Search docs..." | |
| 19 | + class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 20 | + hx-get="{% url 'pages:list' %}" | |
| 21 | + hx-trigger="input changed delay:300ms, search" | |
| 22 | + hx-target="#page-table" | |
| 23 | + hx-swap="outerHTML" | |
| 24 | + /> | |
| 25 | +</div> | |
| 26 | + | |
| 27 | +{% include "pages/partials/page_table.html" %} | |
| 28 | +{% include "includes/_paginatiendblock %} |
| --- a/templates/pages/page_list.html | |
| +++ b/templates/pages/page_list.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/pages/page_list.html | |
| +++ b/templates/pages/page_list.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "baseDocsock title %}FossilRepo Docs — Fossilrepo{% endblock %} |
| 2 | |
| 3 | {% block content %} |
| 4 | <div class="md:flex md:items-center md:justify-between mb-6"> |
| 5 | <hKBt-gray-100">FoDocs</h1> |
| 6 | {% if perms.pages.add_page %} |
| 7 | <a href="{% url 'pages:create' %}" |
| 8 | class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 9 | New Page |
| 10 | </a> |
| 11 | {% endif %} |
| 12 | </div> |
| 13 | |
| 14 | <div class="mb-4"> |
| 15 | <input type="search" |
| 16 | name="search" |
| 17 | value="{{ search }}" |
| 18 | placeholder="Search docs..." |
| 19 | class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 20 | hx-get="{% url 'pages:list' %}" |
| 21 | hx-trigger="input changed delay:300ms, search" |
| 22 | hx-target="#page-table" |
| 23 | hx-swap="outerHTML" |
| 24 | /> |
| 25 | </div> |
| 26 | |
| 27 | {% include "pages/partials/page_table.html" %} |
| 28 | {% include "includes/_paginatiendblock %} |
| --- a/templates/pages/partials/page_table.html | ||
| +++ b/templates/pages/partials/page_table.html | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +<div id="page-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 2 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 3 | + <thead class="bg-gray-900"> | |
| 4 | + <tr> | |
| 5 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Title</th> | |
| 6 | + <th class="px-6 py-3 text-left textext-gray-400">Status</th> | |
| 7 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800"> | |
| 8 | + {% for page in pages %} | |
| 9 | + <t50"> | |
| 10 | + 6 py-4 wh<td class="px-6 py-4 whitespace-nowrap"> | |
| 11 | + <a href="{% url 'pages:detail' slug=page.slug %}" class="text-brand-light hover:text-brand font-medium"> | |
| 12 | + {{ page.name }} | |
| 13 | + </a> | |
| 14 | + </td> | |
| 15 | + <td class="px-6 py-4 whitespace-nowrap"> | |
| 16 | + {% if page.is_published %} | |
| 17 | + <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Published</span> | |
| 18 | + {% else %} | |
| 19 | + <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300">Draft</span> | |
| 20 | + {% endif %} | |
| 21 | + </td> | |
| 22 | + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ page.updated_at|date:"N j, Y" }}</td> | |
| 23 | + <td class="px-6 py-4 whitespace-nowrap text-right text-sm"> | |
| 24 | + {% if perms.pages.change_page %} | |
| 25 | + <a href="{% url 'pages:update' slug=page.slug %}" class="text-brand-light hover:text-brand">Edit</a> | |
| 26 | + {% endif %} | |
| 27 | + </td> | |
| 28 | + </tr> | |
| 29 | + {% empty %} | |
| 30 | + <tr> | |
| 31 | + <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No pages found.</td> | |
| 32 | + </tr> | |
| 33 | + {% endfor %} | |
| 34 | + </tbody> | |
| 35 | + </table> | |
| 36 | + </div> | |
| 37 | +</div> |
| --- a/templates/pages/partials/page_table.html | |
| +++ b/templates/pages/partials/page_table.html | |
| @@ -0,0 +1,37 @@ | |
| --- a/templates/pages/partials/page_table.html | |
| +++ b/templates/pages/partials/page_table.html | |
| @@ -0,0 +1,37 @@ | |
| 1 | <div id="page-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 2 | <table class="min-w-full divide-y divide-gray-700"> |
| 3 | <thead class="bg-gray-900"> |
| 4 | <tr> |
| 5 | <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Title</th> |
| 6 | <th class="px-6 py-3 text-left textext-gray-400">Status</th> |
| 7 | <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800"> |
| 8 | {% for page in pages %} |
| 9 | <t50"> |
| 10 | 6 py-4 wh<td class="px-6 py-4 whitespace-nowrap"> |
| 11 | <a href="{% url 'pages:detail' slug=page.slug %}" class="text-brand-light hover:text-brand font-medium"> |
| 12 | {{ page.name }} |
| 13 | </a> |
| 14 | </td> |
| 15 | <td class="px-6 py-4 whitespace-nowrap"> |
| 16 | {% if page.is_published %} |
| 17 | <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Published</span> |
| 18 | {% else %} |
| 19 | <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300">Draft</span> |
| 20 | {% endif %} |
| 21 | </td> |
| 22 | <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ page.updated_at|date:"N j, Y" }}</td> |
| 23 | <td class="px-6 py-4 whitespace-nowrap text-right text-sm"> |
| 24 | {% if perms.pages.change_page %} |
| 25 | <a href="{% url 'pages:update' slug=page.slug %}" class="text-brand-light hover:text-brand">Edit</a> |
| 26 | {% endif %} |
| 27 | </td> |
| 28 | </tr> |
| 29 | {% empty %} |
| 30 | <tr> |
| 31 | <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No pages found.</td> |
| 32 | </tr> |
| 33 | {% endfor %} |
| 34 | </tbody> |
| 35 | </table> |
| 36 | </div> |
| 37 | </div> |
| --- a/templates/projects/partials/project_table.html | ||
| +++ b/templates/projects/partials/project_table.html | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +<div id="project-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 2 | + <table class="min-w-full divide-y divide-gray-700"> | |
| 3 | + <thead class="bg-gray-900"> | |
| 4 | + <tr> | |
| 5 | + <div id="project-table"> | |
| 6 | + <div class="overflow-x-auto rounded-lg border border-gray-700 bg-grayext-gray-400">Visibility</th> | |
| 7 | + <th class="px-6 py-3 text-left textext-gray-400">Teams</th> | |
| 8 | + <th class="px-6 py-3 text-left text-xs font-medium uppercase t bg-gray-800"> | |
| 9 | + {% for project in projects %} | |
| 10 | + <tr class="hover:bg-gray-700/50 |
| --- a/templates/projects/partials/project_table.html | |
| +++ b/templates/projects/partials/project_table.html | |
| @@ -0,0 +1,10 @@ | |
| --- a/templates/projects/partials/project_table.html | |
| +++ b/templates/projects/partials/project_table.html | |
| @@ -0,0 +1,10 @@ | |
| 1 | <div id="project-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 2 | <table class="min-w-full divide-y divide-gray-700"> |
| 3 | <thead class="bg-gray-900"> |
| 4 | <tr> |
| 5 | <div id="project-table"> |
| 6 | <div class="overflow-x-auto rounded-lg border border-gray-700 bg-grayext-gray-400">Visibility</th> |
| 7 | <th class="px-6 py-3 text-left textext-gray-400">Teams</th> |
| 8 | <th class="px-6 py-3 text-left text-xs font-medium uppercase t bg-gray-800"> |
| 9 | {% for project in projects %} |
| 10 | <tr class="hover:bg-gray-700/50 |
| --- a/templates/projects/partials/project_team_table.html | ||
| +++ b/templates/projects/partials/project_team_table.html | ||
| @@ -0,0 +1 @@ | ||
| 1 | +<div id="project-team-tablhidden |
| --- a/templates/projects/partials/project_team_table.html | |
| +++ b/templates/projects/partials/project_team_table.html | |
| @@ -0,0 +1 @@ | |
| --- a/templates/projects/partials/project_team_table.html | |
| +++ b/templates/projects/partials/project_team_table.html | |
| @@ -0,0 +1 @@ | |
| 1 | <div id="project-team-tablhidden |
| --- a/templates/projects/project_confirm_delete.html | ||
| +++ b/templates/projects/project_confirm_delete.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Delete {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ project.name }}</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-lg"> | |
| 10 | + <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 11 | + <h2 class="text-lg font-semibold text-gray-100">Delete Project</h2> | |
| 12 | + <p class="mt-2 text-sm text-gray-400"> | |
| 13 | + Are you sure you want to delete <strong class="text-gray-100">{{ project.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered. | |
| 14 | + </p> | |
| 15 | + <form method="post" class="mt-6 flex justify-end gap-3"> | |
| 16 | + {% csrf_token %} | |
| 17 | + <a href="{% url 'projects:detail' slug=project.slug %}" | |
| 18 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 19 | + Cancel | |
| 20 | + </a> | |
| 21 | + <button type="submit" | |
| 22 | + class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 23 | + Delete | |
| 24 | + </button> | |
| 25 | + </form> | |
| 26 | + </div> | |
| 27 | +</div> | |
| 28 | +{% endblock %} |
| --- a/templates/projects/project_confirm_delete.html | |
| +++ b/templates/projects/project_confirm_delete.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/projects/project_confirm_delete.html | |
| +++ b/templates/projects/project_confirm_delete.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Delete {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ project.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-lg"> |
| 10 | <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 11 | <h2 class="text-lg font-semibold text-gray-100">Delete Project</h2> |
| 12 | <p class="mt-2 text-sm text-gray-400"> |
| 13 | Are you sure you want to delete <strong class="text-gray-100">{{ project.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered. |
| 14 | </p> |
| 15 | <form method="post" class="mt-6 flex justify-end gap-3"> |
| 16 | {% csrf_token %} |
| 17 | <a href="{% url 'projects:detail' slug=project.slug %}" |
| 18 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 19 | Cancel |
| 20 | </a> |
| 21 | <button type="submit" |
| 22 | class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 23 | Delete |
| 24 | </button> |
| 25 | </form> |
| 26 | </div> |
| 27 | </div> |
| 28 | {% endblock %} |
| --- a/templates/projects/project_detail.html | ||
| +++ b/templates/projects/project_detail.html | ||
| @@ -0,0 +1,8 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% blmb-6"> | |
| 5 | + <a href="{% url 'projects:list':tickets' slug=proje{% extends "base.html" %} | |
| 6 | +{% block k title %}{{ project.name }} — Fossilrepo{% endblock %} | |
| 7 | + | |
| 8 | +{% bl |
| --- a/templates/projects/project_detail.html | |
| +++ b/templates/projects/project_detail.html | |
| @@ -0,0 +1,8 @@ | |
| --- a/templates/projects/project_detail.html | |
| +++ b/templates/projects/project_detail.html | |
| @@ -0,0 +1,8 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% blmb-6"> |
| 5 | <a href="{% url 'projects:list':tickets' slug=proje{% extends "base.html" %} |
| 6 | {% block k title %}{{ project.name }} — Fossilrepo{% endblock %} |
| 7 | |
| 8 | {% bl |
| --- a/templates/projects/project_form.html | ||
| +++ b/templates/projects/project_form.html | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ title }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'projects:list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Projects</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-2xl"> | |
| 10 | + <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> | |
| 11 | + | |
| 12 | + <form method="post" class="space-y-6 roundeg bg-gray-800 p-6 shadow-sm border border-gray-700"> | |
| 13 | + {% csrf_token %} | |
| 14 | + | |
| 15 | + {ow #} | |
| 16 | + {% else %} | |
| 17 | + <div> | |
| 18 | + <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 19 | + {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 20 | + </label> | |
| 21 | + <div class="mt-1">{{ field }}</div> | |
| 22 | + {% if field.errors %} | |
| 23 | + <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 24 | + {% endfor source: '{{ form.repo_source.value|default:"empty" }}' }"> | |
| 25 | + <h3 class="text-sm font-medium text-gray-300 mb-3">Repository Source</h3> | |
| 26 | + <div class="space-y-3"> | |
| 27 | + <label class="flex items-center "> | |
| 28 | +" x-modee="repo_source" value="empty" x-model="source" | |
| 29 | + class="text-brand focus:ring-brand"> | |
| 30 | + <div> | |
| 31 | + <span class="text-sm text-gray- </label> | |
| 32 | + <label class="flex items-center gap-3 cursor-pointer"> | |
| 33 | + <input |
| --- a/templates/projects/project_form.html | |
| +++ b/templates/projects/project_form.html | |
| @@ -0,0 +1,33 @@ | |
| --- a/templates/projects/project_form.html | |
| +++ b/templates/projects/project_form.html | |
| @@ -0,0 +1,33 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ title }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'projects:list' %}" class="text-sm text-brand-light hover:text-brand">← Back to Projects</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-2xl"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1> |
| 11 | |
| 12 | <form method="post" class="space-y-6 roundeg bg-gray-800 p-6 shadow-sm border border-gray-700"> |
| 13 | {% csrf_token %} |
| 14 | |
| 15 | {ow #} |
| 16 | {% else %} |
| 17 | <div> |
| 18 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 19 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 20 | </label> |
| 21 | <div class="mt-1">{{ field }}</div> |
| 22 | {% if field.errors %} |
| 23 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 24 | {% endfor source: '{{ form.repo_source.value|default:"empty" }}' }"> |
| 25 | <h3 class="text-sm font-medium text-gray-300 mb-3">Repository Source</h3> |
| 26 | <div class="space-y-3"> |
| 27 | <label class="flex items-center "> |
| 28 | " x-modee="repo_source" value="empty" x-model="source" |
| 29 | class="text-brand focus:ring-brand"> |
| 30 | <div> |
| 31 | <span class="text-sm text-gray- </label> |
| 32 | <label class="flex items-center gap-3 cursor-pointer"> |
| 33 | <input |
| --- a/templates/projects/project_list.html | ||
| +++ b/templates/projects/project_list.html | ||
| @@ -0,0 +1 @@ | ||
| 1 | +{% extends "base.html"class="w-full max-w-md /endblock %} |
| --- a/templates/projects/project_list.html | |
| +++ b/templates/projects/project_list.html | |
| @@ -0,0 +1 @@ | |
| --- a/templates/projects/project_list.html | |
| +++ b/templates/projects/project_list.html | |
| @@ -0,0 +1 @@ | |
| 1 | {% extends "base.html"class="w-full max-w-md /endblock %} |
| --- a/templates/projects/project_team_add.html | ||
| +++ b/templates/projects/project_team_add.html | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Add Team to {{ project.name }} — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ project.name }}</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-2xl"> | |
| 10 | + <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Team to {{ project.name }}</h1> | |
| 11 | + | |
| 12 | + <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 13 | + {% csrf_token %} | |
| 14 | + | |
| 15 | + {% for field in form %} | |
| 16 | + <div> | |
| 17 | + <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 18 | + {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 19 | + </label> | |
| 20 | + <div class="mt-1">{{ field }}</div> | |
| 21 | + {% if field.errors %} | |
| 22 | + <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 23 | + {% endif %} | |
| 24 | + </div> | |
| 25 | + {% endfor %} | |
| 26 | + | |
| 27 | + <div class="flex justify-end gap-3 pt-4"> | |
| 28 | + <a href="{% url 'projects:detail' slug=project.slug %}" | |
| 29 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 30 | + Cancel | |
| 31 | + </a> | |
| 32 | + <button type="submit" | |
| 33 | + class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 34 | + Add Team | |
| 35 | + </button> | |
| 36 | + </div> | |
| 37 | + </form> | |
| 38 | +</div> | |
| 39 | +{% endblock %} |
| --- a/templates/projects/project_team_add.html | |
| +++ b/templates/projects/project_team_add.html | |
| @@ -0,0 +1,39 @@ | |
| --- a/templates/projects/project_team_add.html | |
| +++ b/templates/projects/project_team_add.html | |
| @@ -0,0 +1,39 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Add Team to {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ project.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-2xl"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Team to {{ project.name }}</h1> |
| 11 | |
| 12 | <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 13 | {% csrf_token %} |
| 14 | |
| 15 | {% for field in form %} |
| 16 | <div> |
| 17 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 18 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 19 | </label> |
| 20 | <div class="mt-1">{{ field }}</div> |
| 21 | {% if field.errors %} |
| 22 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 23 | {% endif %} |
| 24 | </div> |
| 25 | {% endfor %} |
| 26 | |
| 27 | <div class="flex justify-end gap-3 pt-4"> |
| 28 | <a href="{% url 'projects:detail' slug=project.slug %}" |
| 29 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 30 | Cancel |
| 31 | </a> |
| 32 | <button type="submit" |
| 33 | class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 34 | Add Team |
| 35 | </button> |
| 36 | </div> |
| 37 | </form> |
| 38 | </div> |
| 39 | {% endblock %} |
| --- a/templates/projects/project_team_confirm_remove.html | ||
| +++ b/templates/projects/project_team_confirm_remove.html | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Remove Team — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ project.name }}</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-lg"> | |
| 10 | + <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 11 | + <h2 class="text-lg font-semibold text-gray-100">Remove Team</h2> | |
| 12 | + <p class="mt-2 text-sm text-gray-400"> | |
| 13 | + Are you sure you want to remove <strong class="text-gray-100">{{ team.name }}</strong> from <strong class="text-gray-100">{{ project.name }}</strong>? | |
| 14 | + </p> | |
| 15 | + <form method="post" class="mt-6 flex justify-end gap-3"> | |
| 16 | + {% csrf_token %} | |
| 17 | + <a href="{% url 'projects:detail' slug=project.slug %}" | |
| 18 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 19 | + Cancel | |
| 20 | + </a> | |
| 21 | + <button type="submit" | |
| 22 | + class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> | |
| 23 | + Remove | |
| 24 | + </button> | |
| 25 | + </form> | |
| 26 | + </div> | |
| 27 | +</div> | |
| 28 | +{% endblock %} |
| --- a/templates/projects/project_team_confirm_remove.html | |
| +++ b/templates/projects/project_team_confirm_remove.html | |
| @@ -0,0 +1,28 @@ | |
| --- a/templates/projects/project_team_confirm_remove.html | |
| +++ b/templates/projects/project_team_confirm_remove.html | |
| @@ -0,0 +1,28 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Remove Team — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ project.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-lg"> |
| 10 | <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 11 | <h2 class="text-lg font-semibold text-gray-100">Remove Team</h2> |
| 12 | <p class="mt-2 text-sm text-gray-400"> |
| 13 | Are you sure you want to remove <strong class="text-gray-100">{{ team.name }}</strong> from <strong class="text-gray-100">{{ project.name }}</strong>? |
| 14 | </p> |
| 15 | <form method="post" class="mt-6 flex justify-end gap-3"> |
| 16 | {% csrf_token %} |
| 17 | <a href="{% url 'projects:detail' slug=project.slug %}" |
| 18 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 19 | Cancel |
| 20 | </a> |
| 21 | <button type="submit" |
| 22 | class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500"> |
| 23 | Remove |
| 24 | </button> |
| 25 | </form> |
| 26 | </div> |
| 27 | </div> |
| 28 | {% endblock %} |
| --- a/templates/projects/project_team_edit.html | ||
| +++ b/templates/projects/project_team_edit.html | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Edit Team Role — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="mb-6"> | |
| 6 | + <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ project.name }}</a> | |
| 7 | +</div> | |
| 8 | + | |
| 9 | +<div class="mx-auto max-w-2xl"> | |
| 10 | + <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit {{ team.name }} Role on {{ project.name }}</h1> | |
| 11 | + | |
| 12 | + <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> | |
| 13 | + {% csrf_token %} | |
| 14 | + | |
| 15 | + {% for field in form %} | |
| 16 | + <div> | |
| 17 | + <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> | |
| 18 | + {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} | |
| 19 | + </label> | |
| 20 | + <div class="mt-1">{{ field }}</div> | |
| 21 | + {% if field.errors %} | |
| 22 | + <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> | |
| 23 | + {% endif %} | |
| 24 | + </div> | |
| 25 | + {% endfor %} | |
| 26 | + | |
| 27 | + <div class="flex justify-end gap-3 pt-4"> | |
| 28 | + <a href="{% url 'projects:detail' slug=project.slug %}" | |
| 29 | + class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 30 | + Cancel | |
| 31 | + </a> | |
| 32 | + <button type="submit" | |
| 33 | + class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> | |
| 34 | + Update Role | |
| 35 | + </button> | |
| 36 | + </div> | |
| 37 | + </form> | |
| 38 | +</div> | |
| 39 | +{% endblock %} |
| --- a/templates/projects/project_team_edit.html | |
| +++ b/templates/projects/project_team_edit.html | |
| @@ -0,0 +1,39 @@ | |
| --- a/templates/projects/project_team_edit.html | |
| +++ b/templates/projects/project_team_edit.html | |
| @@ -0,0 +1,39 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Edit Team Role — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="mb-6"> |
| 6 | <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">← Back to {{ project.name }}</a> |
| 7 | </div> |
| 8 | |
| 9 | <div class="mx-auto max-w-2xl"> |
| 10 | <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit {{ team.name }} Role on {{ project.name }}</h1> |
| 11 | |
| 12 | <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 13 | {% csrf_token %} |
| 14 | |
| 15 | {% for field in form %} |
| 16 | <div> |
| 17 | <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300"> |
| 18 | {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %} |
| 19 | </label> |
| 20 | <div class="mt-1">{{ field }}</div> |
| 21 | {% if field.errors %} |
| 22 | <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p> |
| 23 | {% endif %} |
| 24 | </div> |
| 25 | {% endfor %} |
| 26 | |
| 27 | <div class="flex justify-end gap-3 pt-4"> |
| 28 | <a href="{% url 'projects:detail' slug=project.slug %}" |
| 29 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 30 | Cancel |
| 31 | </a> |
| 32 | <button type="submit" |
| 33 | class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 34 | Update Role |
| 35 | </button> |
| 36 | </div> |
| 37 | </form> |
| 38 | </div> |
| 39 | {% endblock %} |
| --- testdata/management/commands/seed.py | ||
| +++ testdata/management/commands/seed.py | ||
| @@ -2,11 +2,13 @@ | ||
| 2 | 2 | |
| 3 | 3 | from django.contrib.auth.models import Group, Permission, User |
| 4 | 4 | from django.core.management.base import BaseCommand |
| 5 | 5 | |
| 6 | 6 | from items.models import Item |
| 7 | -from organization.models import Organization, OrganizationMember | |
| 7 | +from organization.models import Organization, OrganizationMember, Team | |
| 8 | +from pages.models import Page | |
| 9 | +from projects.models import Project, ProjectTeam | |
| 8 | 10 | |
| 9 | 11 | logger = logging.getLogger(__name__) |
| 10 | 12 | |
| 11 | 13 | |
| 12 | 14 | class Command(BaseCommand): |
| @@ -15,26 +17,34 @@ | ||
| 15 | 17 | def add_arguments(self, parser): |
| 16 | 18 | parser.add_argument("--flush", action="store_true", help="Flush non-system tables before seeding.") |
| 17 | 19 | |
| 18 | 20 | def handle(self, *args, **options): |
| 19 | 21 | if options["flush"]: |
| 20 | - self.stdout.write("Flushing item and organization data...") | |
| 22 | + self.stdout.write("Flushing data...") | |
| 23 | + Page.all_objects.all().delete() | |
| 24 | + ProjectTeam.all_objects.all().delete() | |
| 25 | + Project.all_objects.all().delete() | |
| 21 | 26 | Item.all_objects.all().delete() |
| 27 | + Team.all_objects.all().delete() | |
| 22 | 28 | OrganizationMember.all_objects.all().delete() |
| 23 | 29 | Organization.all_objects.all().delete() |
| 24 | 30 | |
| 25 | 31 | # Groups and permissions |
| 26 | 32 | admin_group, _ = Group.objects.get_or_create(name="Administrators") |
| 27 | 33 | viewer_group, _ = Group.objects.get_or_create(name="Viewers") |
| 28 | 34 | |
| 29 | - item_perms = Permission.objects.filter(content_type__app_label="items") | |
| 30 | - admin_group.permissions.set(item_perms) | |
| 31 | - view_perms = item_perms.filter(codename__startswith="view_") | |
| 32 | - viewer_group.permissions.set(view_perms) | |
| 35 | + # Admin group gets all permissions for items, org, and projects | |
| 36 | + for app_label in ["items", "organization", "projects", "pages"]: | |
| 37 | + perms = Permission.objects.filter(content_type__app_label=app_label) | |
| 38 | + admin_group.permissions.add(*perms) | |
| 33 | 39 | |
| 34 | - org_perms = Permission.objects.filter(content_type__app_label="organization") | |
| 35 | - admin_group.permissions.add(*org_perms) | |
| 40 | + # Viewer group gets view permissions for items, org, and projects | |
| 41 | + view_perms = Permission.objects.filter( | |
| 42 | + content_type__app_label__in=["items", "organization", "projects", "pages"], | |
| 43 | + codename__startswith="view_", | |
| 44 | + ) | |
| 45 | + viewer_group.permissions.set(view_perms) | |
| 36 | 46 | |
| 37 | 47 | # Superuser |
| 38 | 48 | admin_user, created = User.objects.get_or_create( |
| 39 | 49 | username="admin", |
| 40 | 50 | defaults={"email": "[email protected]", "is_staff": True, "is_superuser": True}, |
| @@ -58,10 +68,50 @@ | ||
| 58 | 68 | # Organization |
| 59 | 69 | org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"}) |
| 60 | 70 | OrganizationMember.objects.get_or_create(member=admin_user, organization=org) |
| 61 | 71 | OrganizationMember.objects.get_or_create(member=viewer_user, organization=org) |
| 62 | 72 | |
| 73 | + # Teams | |
| 74 | + core_devs, _ = Team.objects.get_or_create(name="Core Devs", defaults={"organization": org, "description": "Core development team"}) | |
| 75 | + core_devs.members.add(admin_user) | |
| 76 | + | |
| 77 | + contributors, _ = Team.objects.get_or_create( | |
| 78 | + name="Contributors", defaults={"organization": org, "description": "Community contributors"} | |
| 79 | + ) | |
| 80 | + contributors.members.add(viewer_user) | |
| 81 | + | |
| 82 | + reviewers, _ = Team.objects.get_or_create(name="Reviewers", defaults={"organization": org, "description": "Code review team"}) | |
| 83 | + reviewers.members.add(admin_user, viewer_user) | |
| 84 | + | |
| 85 | + # Projects | |
| 86 | + projects_data = [ | |
| 87 | + {"name": "Frontend App", "description": "User-facing web application", "visibility": "internal"}, | |
| 88 | + {"name": "Backend API", "description": "Core API service", "visibility": "private"}, | |
| 89 | + {"name": "Documentation", "description": "Project documentation and guides", "visibility": "public"}, | |
| 90 | + {"name": "Infrastructure", "description": "Deployment and infrastructure tooling", "visibility": "private"}, | |
| 91 | + ] | |
| 92 | + for pdata in projects_data: | |
| 93 | + project, _ = Project.objects.get_or_create( | |
| 94 | + name=pdata["name"], | |
| 95 | + defaults={**pdata, "organization": org, "created_by": admin_user}, | |
| 96 | + ) | |
| 97 | + | |
| 98 | + # Team-project assignments | |
| 99 | + frontend = Project.objects.filter(name="Frontend App").first() | |
| 100 | + backend = Project.objects.filter(name="Backend API").first() | |
| 101 | + docs = Project.objects.filter(name="Documentation").first() | |
| 102 | + | |
| 103 | + if frontend: | |
| 104 | + ProjectTeam.objects.get_or_create(project=frontend, team=core_devs, defaults={"role": "admin"}) | |
| 105 | + ProjectTeam.objects.get_or_create(project=frontend, team=contributors, defaults={"role": "write"}) | |
| 106 | + if backend: | |
| 107 | + ProjectTeam.objects.get_or_create(project=backend, team=core_devs, defaults={"role": "admin"}) | |
| 108 | + ProjectTeam.objects.get_or_create(project=backend, team=reviewers, defaults={"role": "read"}) | |
| 109 | + if docs: | |
| 110 | + ProjectTeam.objects.get_or_create(project=docs, team=contributors, defaults={"role": "write"}) | |
| 111 | + ProjectTeam.objects.get_or_create(project=docs, team=reviewers, defaults={"role": "write"}) | |
| 112 | + | |
| 63 | 113 | # Sample items |
| 64 | 114 | items_data = [ |
| 65 | 115 | {"name": "Widget Alpha", "price": "29.99", "sku": "WGT-001", "description": "A versatile alpha widget."}, |
| 66 | 116 | {"name": "Widget Beta", "price": "49.99", "sku": "WGT-002", "description": "Enhanced beta widget with extra features."}, |
| 67 | 117 | {"name": "Gadget Pro", "price": "199.99", "sku": "GDG-001", "description": "Professional-grade gadget."}, |
| @@ -71,7 +121,28 @@ | ||
| 71 | 121 | for data in items_data: |
| 72 | 122 | Item.objects.get_or_create( |
| 73 | 123 | sku=data["sku"], |
| 74 | 124 | defaults={**data, "created_by": admin_user}, |
| 75 | 125 | ) |
| 126 | + | |
| 127 | + # Sample docs pages | |
| 128 | + pages_data = [ | |
| 129 | + { | |
| 130 | + "name": "Getting Started", | |
| 131 | + "content": "# Getting Started\n\nWelcome to Fossilrepo. This guide covers initial setup and configuration.\n\n## Prerequisites\n\n- Docker and Docker Compose\n- A domain name (for SSL)\n- S3-compatible storage (for backups)\n\n## Quick Start\n\n1. Clone the repository\n2. Copy `.env.example` to `.env`\n3. Run `fossilrepo-ctl reconfigure`\n4. Run `fossilrepo-ctl start`\n", | |
| 132 | + }, | |
| 133 | + { | |
| 134 | + "name": "Admin Guide", | |
| 135 | + "content": "# Admin Guide\n\nThis guide covers day-to-day administration of your Fossilrepo instance.\n\n## Managing Users\n\nUsers can be added through the Django admin or the Settings > Members page.\n\n## Backups\n\nLitestream continuously replicates all `.fossil` files to S3. Manual backups can be created with `fossilrepo-ctl backup create`.\n\n## Monitoring\n\nCheck `/health/` for service status and `/status/` for an overview page.\n", | |
| 136 | + }, | |
| 137 | + { | |
| 138 | + "name": "Architecture Overview", | |
| 139 | + "content": "# Architecture Overview\n\n## Stack\n\n| Component | Technology |\n|-----------|------------|\n| Backend | Django 5 + HTMX |\n| Database | PostgreSQL 16 |\n| SCM | Fossil |\n| Proxy | Caddy |\n| Backups | Litestream → S3 |\n| Jobs | Celery + Redis |\n\n## How It Works\n\nEach Fossil repository is a single `.fossil` SQLite file. Caddy routes subdomain requests to the Fossil server. Django provides the management UI. Litestream continuously replicates repo files to S3.\n", | |
| 140 | + }, | |
| 141 | + ] | |
| 142 | + for pdata in pages_data: | |
| 143 | + Page.objects.get_or_create( | |
| 144 | + name=pdata["name"], | |
| 145 | + defaults={**pdata, "organization": org, "created_by": admin_user}, | |
| 146 | + ) | |
| 76 | 147 | |
| 77 | 148 | self.stdout.write(self.style.SUCCESS("Seed complete.")) |
| 78 | 149 | |
| 79 | 150 | ADDED tests/__init__.py |
| --- testdata/management/commands/seed.py | |
| +++ testdata/management/commands/seed.py | |
| @@ -2,11 +2,13 @@ | |
| 2 | |
| 3 | from django.contrib.auth.models import Group, Permission, User |
| 4 | from django.core.management.base import BaseCommand |
| 5 | |
| 6 | from items.models import Item |
| 7 | from organization.models import Organization, OrganizationMember |
| 8 | |
| 9 | logger = logging.getLogger(__name__) |
| 10 | |
| 11 | |
| 12 | class Command(BaseCommand): |
| @@ -15,26 +17,34 @@ | |
| 15 | def add_arguments(self, parser): |
| 16 | parser.add_argument("--flush", action="store_true", help="Flush non-system tables before seeding.") |
| 17 | |
| 18 | def handle(self, *args, **options): |
| 19 | if options["flush"]: |
| 20 | self.stdout.write("Flushing item and organization data...") |
| 21 | Item.all_objects.all().delete() |
| 22 | OrganizationMember.all_objects.all().delete() |
| 23 | Organization.all_objects.all().delete() |
| 24 | |
| 25 | # Groups and permissions |
| 26 | admin_group, _ = Group.objects.get_or_create(name="Administrators") |
| 27 | viewer_group, _ = Group.objects.get_or_create(name="Viewers") |
| 28 | |
| 29 | item_perms = Permission.objects.filter(content_type__app_label="items") |
| 30 | admin_group.permissions.set(item_perms) |
| 31 | view_perms = item_perms.filter(codename__startswith="view_") |
| 32 | viewer_group.permissions.set(view_perms) |
| 33 | |
| 34 | org_perms = Permission.objects.filter(content_type__app_label="organization") |
| 35 | admin_group.permissions.add(*org_perms) |
| 36 | |
| 37 | # Superuser |
| 38 | admin_user, created = User.objects.get_or_create( |
| 39 | username="admin", |
| 40 | defaults={"email": "[email protected]", "is_staff": True, "is_superuser": True}, |
| @@ -58,10 +68,50 @@ | |
| 58 | # Organization |
| 59 | org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"}) |
| 60 | OrganizationMember.objects.get_or_create(member=admin_user, organization=org) |
| 61 | OrganizationMember.objects.get_or_create(member=viewer_user, organization=org) |
| 62 | |
| 63 | # Sample items |
| 64 | items_data = [ |
| 65 | {"name": "Widget Alpha", "price": "29.99", "sku": "WGT-001", "description": "A versatile alpha widget."}, |
| 66 | {"name": "Widget Beta", "price": "49.99", "sku": "WGT-002", "description": "Enhanced beta widget with extra features."}, |
| 67 | {"name": "Gadget Pro", "price": "199.99", "sku": "GDG-001", "description": "Professional-grade gadget."}, |
| @@ -71,7 +121,28 @@ | |
| 71 | for data in items_data: |
| 72 | Item.objects.get_or_create( |
| 73 | sku=data["sku"], |
| 74 | defaults={**data, "created_by": admin_user}, |
| 75 | ) |
| 76 | |
| 77 | self.stdout.write(self.style.SUCCESS("Seed complete.")) |
| 78 | |
| 79 | DDED tests/__init__.py |
| --- testdata/management/commands/seed.py | |
| +++ testdata/management/commands/seed.py | |
| @@ -2,11 +2,13 @@ | |
| 2 | |
| 3 | from django.contrib.auth.models import Group, Permission, User |
| 4 | from django.core.management.base import BaseCommand |
| 5 | |
| 6 | from items.models import Item |
| 7 | from organization.models import Organization, OrganizationMember, Team |
| 8 | from pages.models import Page |
| 9 | from projects.models import Project, ProjectTeam |
| 10 | |
| 11 | logger = logging.getLogger(__name__) |
| 12 | |
| 13 | |
| 14 | class Command(BaseCommand): |
| @@ -15,26 +17,34 @@ | |
| 17 | def add_arguments(self, parser): |
| 18 | parser.add_argument("--flush", action="store_true", help="Flush non-system tables before seeding.") |
| 19 | |
| 20 | def handle(self, *args, **options): |
| 21 | if options["flush"]: |
| 22 | self.stdout.write("Flushing data...") |
| 23 | Page.all_objects.all().delete() |
| 24 | ProjectTeam.all_objects.all().delete() |
| 25 | Project.all_objects.all().delete() |
| 26 | Item.all_objects.all().delete() |
| 27 | Team.all_objects.all().delete() |
| 28 | OrganizationMember.all_objects.all().delete() |
| 29 | Organization.all_objects.all().delete() |
| 30 | |
| 31 | # Groups and permissions |
| 32 | admin_group, _ = Group.objects.get_or_create(name="Administrators") |
| 33 | viewer_group, _ = Group.objects.get_or_create(name="Viewers") |
| 34 | |
| 35 | # Admin group gets all permissions for items, org, and projects |
| 36 | for app_label in ["items", "organization", "projects", "pages"]: |
| 37 | perms = Permission.objects.filter(content_type__app_label=app_label) |
| 38 | admin_group.permissions.add(*perms) |
| 39 | |
| 40 | # Viewer group gets view permissions for items, org, and projects |
| 41 | view_perms = Permission.objects.filter( |
| 42 | content_type__app_label__in=["items", "organization", "projects", "pages"], |
| 43 | codename__startswith="view_", |
| 44 | ) |
| 45 | viewer_group.permissions.set(view_perms) |
| 46 | |
| 47 | # Superuser |
| 48 | admin_user, created = User.objects.get_or_create( |
| 49 | username="admin", |
| 50 | defaults={"email": "[email protected]", "is_staff": True, "is_superuser": True}, |
| @@ -58,10 +68,50 @@ | |
| 68 | # Organization |
| 69 | org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"}) |
| 70 | OrganizationMember.objects.get_or_create(member=admin_user, organization=org) |
| 71 | OrganizationMember.objects.get_or_create(member=viewer_user, organization=org) |
| 72 | |
| 73 | # Teams |
| 74 | core_devs, _ = Team.objects.get_or_create(name="Core Devs", defaults={"organization": org, "description": "Core development team"}) |
| 75 | core_devs.members.add(admin_user) |
| 76 | |
| 77 | contributors, _ = Team.objects.get_or_create( |
| 78 | name="Contributors", defaults={"organization": org, "description": "Community contributors"} |
| 79 | ) |
| 80 | contributors.members.add(viewer_user) |
| 81 | |
| 82 | reviewers, _ = Team.objects.get_or_create(name="Reviewers", defaults={"organization": org, "description": "Code review team"}) |
| 83 | reviewers.members.add(admin_user, viewer_user) |
| 84 | |
| 85 | # Projects |
| 86 | projects_data = [ |
| 87 | {"name": "Frontend App", "description": "User-facing web application", "visibility": "internal"}, |
| 88 | {"name": "Backend API", "description": "Core API service", "visibility": "private"}, |
| 89 | {"name": "Documentation", "description": "Project documentation and guides", "visibility": "public"}, |
| 90 | {"name": "Infrastructure", "description": "Deployment and infrastructure tooling", "visibility": "private"}, |
| 91 | ] |
| 92 | for pdata in projects_data: |
| 93 | project, _ = Project.objects.get_or_create( |
| 94 | name=pdata["name"], |
| 95 | defaults={**pdata, "organization": org, "created_by": admin_user}, |
| 96 | ) |
| 97 | |
| 98 | # Team-project assignments |
| 99 | frontend = Project.objects.filter(name="Frontend App").first() |
| 100 | backend = Project.objects.filter(name="Backend API").first() |
| 101 | docs = Project.objects.filter(name="Documentation").first() |
| 102 | |
| 103 | if frontend: |
| 104 | ProjectTeam.objects.get_or_create(project=frontend, team=core_devs, defaults={"role": "admin"}) |
| 105 | ProjectTeam.objects.get_or_create(project=frontend, team=contributors, defaults={"role": "write"}) |
| 106 | if backend: |
| 107 | ProjectTeam.objects.get_or_create(project=backend, team=core_devs, defaults={"role": "admin"}) |
| 108 | ProjectTeam.objects.get_or_create(project=backend, team=reviewers, defaults={"role": "read"}) |
| 109 | if docs: |
| 110 | ProjectTeam.objects.get_or_create(project=docs, team=contributors, defaults={"role": "write"}) |
| 111 | ProjectTeam.objects.get_or_create(project=docs, team=reviewers, defaults={"role": "write"}) |
| 112 | |
| 113 | # Sample items |
| 114 | items_data = [ |
| 115 | {"name": "Widget Alpha", "price": "29.99", "sku": "WGT-001", "description": "A versatile alpha widget."}, |
| 116 | {"name": "Widget Beta", "price": "49.99", "sku": "WGT-002", "description": "Enhanced beta widget with extra features."}, |
| 117 | {"name": "Gadget Pro", "price": "199.99", "sku": "GDG-001", "description": "Professional-grade gadget."}, |
| @@ -71,7 +121,28 @@ | |
| 121 | for data in items_data: |
| 122 | Item.objects.get_or_create( |
| 123 | sku=data["sku"], |
| 124 | defaults={**data, "created_by": admin_user}, |
| 125 | ) |
| 126 | |
| 127 | # Sample docs pages |
| 128 | pages_data = [ |
| 129 | { |
| 130 | "name": "Getting Started", |
| 131 | "content": "# Getting Started\n\nWelcome to Fossilrepo. This guide covers initial setup and configuration.\n\n## Prerequisites\n\n- Docker and Docker Compose\n- A domain name (for SSL)\n- S3-compatible storage (for backups)\n\n## Quick Start\n\n1. Clone the repository\n2. Copy `.env.example` to `.env`\n3. Run `fossilrepo-ctl reconfigure`\n4. Run `fossilrepo-ctl start`\n", |
| 132 | }, |
| 133 | { |
| 134 | "name": "Admin Guide", |
| 135 | "content": "# Admin Guide\n\nThis guide covers day-to-day administration of your Fossilrepo instance.\n\n## Managing Users\n\nUsers can be added through the Django admin or the Settings > Members page.\n\n## Backups\n\nLitestream continuously replicates all `.fossil` files to S3. Manual backups can be created with `fossilrepo-ctl backup create`.\n\n## Monitoring\n\nCheck `/health/` for service status and `/status/` for an overview page.\n", |
| 136 | }, |
| 137 | { |
| 138 | "name": "Architecture Overview", |
| 139 | "content": "# Architecture Overview\n\n## Stack\n\n| Component | Technology |\n|-----------|------------|\n| Backend | Django 5 + HTMX |\n| Database | PostgreSQL 16 |\n| SCM | Fossil |\n| Proxy | Caddy |\n| Backups | Litestream → S3 |\n| Jobs | Celery + Redis |\n\n## How It Works\n\nEach Fossil repository is a single `.fossil` SQLite file. Caddy routes subdomain requests to the Fossil server. Django provides the management UI. Litestream continuously replicates repo files to S3.\n", |
| 140 | }, |
| 141 | ] |
| 142 | for pdata in pages_data: |
| 143 | Page.objects.get_or_create( |
| 144 | name=pdata["name"], |
| 145 | defaults={**pdata, "organization": org, "created_by": admin_user}, |
| 146 | ) |
| 147 | |
| 148 | self.stdout.write(self.style.SUCCESS("Seed complete.")) |
| 149 | |
| 150 | DDED tests/__init__.py |
No diff available
| --- uv.lock | ||
| +++ uv.lock | ||
| @@ -30,76 +30,10 @@ | ||
| 30 | 30 | sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } |
| 31 | 31 | wheels = [ |
| 32 | 32 | { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, |
| 33 | 33 | ] |
| 34 | 34 | |
| 35 | -[[package]] | |
| 36 | -name = "boilerworks-django-htmx" | |
| 37 | -version = "0.1.0" | |
| 38 | -source = { editable = "." } | |
| 39 | -dependencies = [ | |
| 40 | - { name = "boto3" }, | |
| 41 | - { name = "celery", extra = ["redis"] }, | |
| 42 | - { name = "django" }, | |
| 43 | - { name = "django-celery-beat" }, | |
| 44 | - { name = "django-celery-results" }, | |
| 45 | - { name = "django-constance" }, | |
| 46 | - { name = "django-cors-headers" }, | |
| 47 | - { name = "django-health-check" }, | |
| 48 | - { name = "django-import-export" }, | |
| 49 | - { name = "django-ratelimit" }, | |
| 50 | - { name = "django-ses" }, | |
| 51 | - { name = "django-simple-history" }, | |
| 52 | - { name = "django-storages", extra = ["s3"] }, | |
| 53 | - { name = "gunicorn" }, | |
| 54 | - { name = "psycopg2-binary" }, | |
| 55 | - { name = "redis" }, | |
| 56 | - { name = "sentry-sdk", extra = ["django"] }, | |
| 57 | - { name = "whitenoise" }, | |
| 58 | -] | |
| 59 | - | |
| 60 | -[package.optional-dependencies] | |
| 61 | -dev = [ | |
| 62 | - { name = "coverage" }, | |
| 63 | - { name = "freezegun" }, | |
| 64 | - { name = "pip-audit" }, | |
| 65 | - { name = "pytest" }, | |
| 66 | - { name = "pytest-cov" }, | |
| 67 | - { name = "pytest-django" }, | |
| 68 | - { name = "ruff" }, | |
| 69 | -] | |
| 70 | - | |
| 71 | -[package.metadata] | |
| 72 | -requires-dist = [ | |
| 73 | - { name = "boto3", specifier = ">=1.35" }, | |
| 74 | - { name = "celery", extras = ["redis"], specifier = ">=5.4" }, | |
| 75 | - { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, | |
| 76 | - { name = "django", specifier = ">=5.1,<6.0" }, | |
| 77 | - { name = "django-celery-beat", specifier = ">=2.7" }, | |
| 78 | - { name = "django-celery-results", specifier = ">=2.5" }, | |
| 79 | - { name = "django-constance", extras = ["database"], specifier = ">=4.1" }, | |
| 80 | - { name = "django-cors-headers", specifier = ">=4.4" }, | |
| 81 | - { name = "django-health-check", specifier = ">=3.18" }, | |
| 82 | - { name = "django-import-export", specifier = ">=4.0" }, | |
| 83 | - { name = "django-ratelimit", specifier = ">=4.1" }, | |
| 84 | - { name = "django-ses", specifier = ">=4.1" }, | |
| 85 | - { name = "django-simple-history", specifier = ">=3.7" }, | |
| 86 | - { name = "django-storages", extras = ["s3"], specifier = ">=1.14" }, | |
| 87 | - { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" }, | |
| 88 | - { name = "gunicorn", specifier = ">=23.0" }, | |
| 89 | - { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" }, | |
| 90 | - { name = "psycopg2-binary", specifier = ">=2.9" }, | |
| 91 | - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, | |
| 92 | - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, | |
| 93 | - { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" }, | |
| 94 | - { name = "redis", specifier = ">=5.0" }, | |
| 95 | - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" }, | |
| 96 | - { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" }, | |
| 97 | - { name = "whitenoise", specifier = ">=6.7" }, | |
| 98 | -] | |
| 99 | -provides-extras = ["dev"] | |
| 100 | - | |
| 101 | 35 | [[package]] |
| 102 | 36 | name = "boolean-py" |
| 103 | 37 | version = "5.0" |
| 104 | 38 | source = { registry = "https://pypi.org/simple" } |
| 105 | 39 | sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } |
| @@ -617,10 +551,82 @@ | ||
| 617 | 551 | sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } |
| 618 | 552 | wheels = [ |
| 619 | 553 | { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, |
| 620 | 554 | ] |
| 621 | 555 | |
| 556 | +[[package]] | |
| 557 | +name = "fossilrepo" | |
| 558 | +version = "0.1.0" | |
| 559 | +source = { editable = "." } | |
| 560 | +dependencies = [ | |
| 561 | + { name = "boto3" }, | |
| 562 | + { name = "celery", extra = ["redis"] }, | |
| 563 | + { name = "click" }, | |
| 564 | + { name = "django" }, | |
| 565 | + { name = "django-celery-beat" }, | |
| 566 | + { name = "django-celery-results" }, | |
| 567 | + { name = "django-constance" }, | |
| 568 | + { name = "django-cors-headers" }, | |
| 569 | + { name = "django-health-check" }, | |
| 570 | + { name = "django-import-export" }, | |
| 571 | + { name = "django-ratelimit" }, | |
| 572 | + { name = "django-ses" }, | |
| 573 | + { name = "django-simple-history" }, | |
| 574 | + { name = "django-storages", extra = ["s3"] }, | |
| 575 | + { name = "gunicorn" }, | |
| 576 | + { name = "markdown" }, | |
| 577 | + { name = "psycopg2-binary" }, | |
| 578 | + { name = "redis" }, | |
| 579 | + { name = "rich" }, | |
| 580 | + { name = "sentry-sdk", extra = ["django"] }, | |
| 581 | + { name = "whitenoise" }, | |
| 582 | +] | |
| 583 | + | |
| 584 | +[package.optional-dependencies] | |
| 585 | +dev = [ | |
| 586 | + { name = "coverage" }, | |
| 587 | + { name = "freezegun" }, | |
| 588 | + { name = "pip-audit" }, | |
| 589 | + { name = "pytest" }, | |
| 590 | + { name = "pytest-cov" }, | |
| 591 | + { name = "pytest-django" }, | |
| 592 | + { name = "ruff" }, | |
| 593 | +] | |
| 594 | + | |
| 595 | +[package.metadata] | |
| 596 | +requires-dist = [ | |
| 597 | + { name = "boto3", specifier = ">=1.35" }, | |
| 598 | + { name = "celery", extras = ["redis"], specifier = ">=5.4" }, | |
| 599 | + { name = "click", specifier = ">=8.1" }, | |
| 600 | + { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, | |
| 601 | + { name = "django", specifier = ">=5.1,<6.0" }, | |
| 602 | + { name = "django-celery-beat", specifier = ">=2.7" }, | |
| 603 | + { name = "django-celery-results", specifier = ">=2.5" }, | |
| 604 | + { name = "django-constance", extras = ["database"], specifier = ">=4.1" }, | |
| 605 | + { name = "django-cors-headers", specifier = ">=4.4" }, | |
| 606 | + { name = "django-health-check", specifier = ">=3.18" }, | |
| 607 | + { name = "django-import-export", specifier = ">=4.0" }, | |
| 608 | + { name = "django-ratelimit", specifier = ">=4.1" }, | |
| 609 | + { name = "django-ses", specifier = ">=4.1" }, | |
| 610 | + { name = "django-simple-history", specifier = ">=3.7" }, | |
| 611 | + { name = "django-storages", extras = ["s3"], specifier = ">=1.14" }, | |
| 612 | + { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" }, | |
| 613 | + { name = "gunicorn", specifier = ">=23.0" }, | |
| 614 | + { name = "markdown", specifier = ">=3.6" }, | |
| 615 | + { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" }, | |
| 616 | + { name = "psycopg2-binary", specifier = ">=2.9" }, | |
| 617 | + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, | |
| 618 | + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, | |
| 619 | + { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" }, | |
| 620 | + { name = "redis", specifier = ">=5.0" }, | |
| 621 | + { name = "rich", specifier = ">=13.0" }, | |
| 622 | + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" }, | |
| 623 | + { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" }, | |
| 624 | + { name = "whitenoise", specifier = ">=6.7" }, | |
| 625 | +] | |
| 626 | +provides-extras = ["dev"] | |
| 627 | + | |
| 622 | 628 | [[package]] |
| 623 | 629 | name = "freezegun" |
| 624 | 630 | version = "1.5.5" |
| 625 | 631 | source = { registry = "https://pypi.org/simple" } |
| 626 | 632 | dependencies = [ |
| @@ -699,10 +705,19 @@ | ||
| 699 | 705 | ] |
| 700 | 706 | sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } |
| 701 | 707 | wheels = [ |
| 702 | 708 | { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, |
| 703 | 709 | ] |
| 710 | + | |
| 711 | +[[package]] | |
| 712 | +name = "markdown" | |
| 713 | +version = "3.10.2" | |
| 714 | +source = { registry = "https://pypi.org/simple" } | |
| 715 | +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } | |
| 716 | +wheels = [ | |
| 717 | + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, | |
| 718 | +] | |
| 704 | 719 | |
| 705 | 720 | [[package]] |
| 706 | 721 | name = "markdown-it-py" |
| 707 | 722 | version = "4.0.0" |
| 708 | 723 | source = { registry = "https://pypi.org/simple" } |
| 709 | 724 |
| --- uv.lock | |
| +++ uv.lock | |
| @@ -30,76 +30,10 @@ | |
| 30 | sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } |
| 31 | wheels = [ |
| 32 | { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, |
| 33 | ] |
| 34 | |
| 35 | [[package]] |
| 36 | name = "boilerworks-django-htmx" |
| 37 | version = "0.1.0" |
| 38 | source = { editable = "." } |
| 39 | dependencies = [ |
| 40 | { name = "boto3" }, |
| 41 | { name = "celery", extra = ["redis"] }, |
| 42 | { name = "django" }, |
| 43 | { name = "django-celery-beat" }, |
| 44 | { name = "django-celery-results" }, |
| 45 | { name = "django-constance" }, |
| 46 | { name = "django-cors-headers" }, |
| 47 | { name = "django-health-check" }, |
| 48 | { name = "django-import-export" }, |
| 49 | { name = "django-ratelimit" }, |
| 50 | { name = "django-ses" }, |
| 51 | { name = "django-simple-history" }, |
| 52 | { name = "django-storages", extra = ["s3"] }, |
| 53 | { name = "gunicorn" }, |
| 54 | { name = "psycopg2-binary" }, |
| 55 | { name = "redis" }, |
| 56 | { name = "sentry-sdk", extra = ["django"] }, |
| 57 | { name = "whitenoise" }, |
| 58 | ] |
| 59 | |
| 60 | [package.optional-dependencies] |
| 61 | dev = [ |
| 62 | { name = "coverage" }, |
| 63 | { name = "freezegun" }, |
| 64 | { name = "pip-audit" }, |
| 65 | { name = "pytest" }, |
| 66 | { name = "pytest-cov" }, |
| 67 | { name = "pytest-django" }, |
| 68 | { name = "ruff" }, |
| 69 | ] |
| 70 | |
| 71 | [package.metadata] |
| 72 | requires-dist = [ |
| 73 | { name = "boto3", specifier = ">=1.35" }, |
| 74 | { name = "celery", extras = ["redis"], specifier = ">=5.4" }, |
| 75 | { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, |
| 76 | { name = "django", specifier = ">=5.1,<6.0" }, |
| 77 | { name = "django-celery-beat", specifier = ">=2.7" }, |
| 78 | { name = "django-celery-results", specifier = ">=2.5" }, |
| 79 | { name = "django-constance", extras = ["database"], specifier = ">=4.1" }, |
| 80 | { name = "django-cors-headers", specifier = ">=4.4" }, |
| 81 | { name = "django-health-check", specifier = ">=3.18" }, |
| 82 | { name = "django-import-export", specifier = ">=4.0" }, |
| 83 | { name = "django-ratelimit", specifier = ">=4.1" }, |
| 84 | { name = "django-ses", specifier = ">=4.1" }, |
| 85 | { name = "django-simple-history", specifier = ">=3.7" }, |
| 86 | { name = "django-storages", extras = ["s3"], specifier = ">=1.14" }, |
| 87 | { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" }, |
| 88 | { name = "gunicorn", specifier = ">=23.0" }, |
| 89 | { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" }, |
| 90 | { name = "psycopg2-binary", specifier = ">=2.9" }, |
| 91 | { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, |
| 92 | { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, |
| 93 | { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" }, |
| 94 | { name = "redis", specifier = ">=5.0" }, |
| 95 | { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" }, |
| 96 | { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" }, |
| 97 | { name = "whitenoise", specifier = ">=6.7" }, |
| 98 | ] |
| 99 | provides-extras = ["dev"] |
| 100 | |
| 101 | [[package]] |
| 102 | name = "boolean-py" |
| 103 | version = "5.0" |
| 104 | source = { registry = "https://pypi.org/simple" } |
| 105 | sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } |
| @@ -617,10 +551,82 @@ | |
| 617 | sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } |
| 618 | wheels = [ |
| 619 | { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, |
| 620 | ] |
| 621 | |
| 622 | [[package]] |
| 623 | name = "freezegun" |
| 624 | version = "1.5.5" |
| 625 | source = { registry = "https://pypi.org/simple" } |
| 626 | dependencies = [ |
| @@ -699,10 +705,19 @@ | |
| 699 | ] |
| 700 | sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } |
| 701 | wheels = [ |
| 702 | { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, |
| 703 | ] |
| 704 | |
| 705 | [[package]] |
| 706 | name = "markdown-it-py" |
| 707 | version = "4.0.0" |
| 708 | source = { registry = "https://pypi.org/simple" } |
| 709 |
| --- uv.lock | |
| +++ uv.lock | |
| @@ -30,76 +30,10 @@ | |
| 30 | sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } |
| 31 | wheels = [ |
| 32 | { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, |
| 33 | ] |
| 34 | |
| 35 | [[package]] |
| 36 | name = "boolean-py" |
| 37 | version = "5.0" |
| 38 | source = { registry = "https://pypi.org/simple" } |
| 39 | sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } |
| @@ -617,10 +551,82 @@ | |
| 551 | sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } |
| 552 | wheels = [ |
| 553 | { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, |
| 554 | ] |
| 555 | |
| 556 | [[package]] |
| 557 | name = "fossilrepo" |
| 558 | version = "0.1.0" |
| 559 | source = { editable = "." } |
| 560 | dependencies = [ |
| 561 | { name = "boto3" }, |
| 562 | { name = "celery", extra = ["redis"] }, |
| 563 | { name = "click" }, |
| 564 | { name = "django" }, |
| 565 | { name = "django-celery-beat" }, |
| 566 | { name = "django-celery-results" }, |
| 567 | { name = "django-constance" }, |
| 568 | { name = "django-cors-headers" }, |
| 569 | { name = "django-health-check" }, |
| 570 | { name = "django-import-export" }, |
| 571 | { name = "django-ratelimit" }, |
| 572 | { name = "django-ses" }, |
| 573 | { name = "django-simple-history" }, |
| 574 | { name = "django-storages", extra = ["s3"] }, |
| 575 | { name = "gunicorn" }, |
| 576 | { name = "markdown" }, |
| 577 | { name = "psycopg2-binary" }, |
| 578 | { name = "redis" }, |
| 579 | { name = "rich" }, |
| 580 | { name = "sentry-sdk", extra = ["django"] }, |
| 581 | { name = "whitenoise" }, |
| 582 | ] |
| 583 | |
| 584 | [package.optional-dependencies] |
| 585 | dev = [ |
| 586 | { name = "coverage" }, |
| 587 | { name = "freezegun" }, |
| 588 | { name = "pip-audit" }, |
| 589 | { name = "pytest" }, |
| 590 | { name = "pytest-cov" }, |
| 591 | { name = "pytest-django" }, |
| 592 | { name = "ruff" }, |
| 593 | ] |
| 594 | |
| 595 | [package.metadata] |
| 596 | requires-dist = [ |
| 597 | { name = "boto3", specifier = ">=1.35" }, |
| 598 | { name = "celery", extras = ["redis"], specifier = ">=5.4" }, |
| 599 | { name = "click", specifier = ">=8.1" }, |
| 600 | { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, |
| 601 | { name = "django", specifier = ">=5.1,<6.0" }, |
| 602 | { name = "django-celery-beat", specifier = ">=2.7" }, |
| 603 | { name = "django-celery-results", specifier = ">=2.5" }, |
| 604 | { name = "django-constance", extras = ["database"], specifier = ">=4.1" }, |
| 605 | { name = "django-cors-headers", specifier = ">=4.4" }, |
| 606 | { name = "django-health-check", specifier = ">=3.18" }, |
| 607 | { name = "django-import-export", specifier = ">=4.0" }, |
| 608 | { name = "django-ratelimit", specifier = ">=4.1" }, |
| 609 | { name = "django-ses", specifier = ">=4.1" }, |
| 610 | { name = "django-simple-history", specifier = ">=3.7" }, |
| 611 | { name = "django-storages", extras = ["s3"], specifier = ">=1.14" }, |
| 612 | { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" }, |
| 613 | { name = "gunicorn", specifier = ">=23.0" }, |
| 614 | { name = "markdown", specifier = ">=3.6" }, |
| 615 | { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" }, |
| 616 | { name = "psycopg2-binary", specifier = ">=2.9" }, |
| 617 | { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, |
| 618 | { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, |
| 619 | { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" }, |
| 620 | { name = "redis", specifier = ">=5.0" }, |
| 621 | { name = "rich", specifier = ">=13.0" }, |
| 622 | { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" }, |
| 623 | { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" }, |
| 624 | { name = "whitenoise", specifier = ">=6.7" }, |
| 625 | ] |
| 626 | provides-extras = ["dev"] |
| 627 | |
| 628 | [[package]] |
| 629 | name = "freezegun" |
| 630 | version = "1.5.5" |
| 631 | source = { registry = "https://pypi.org/simple" } |
| 632 | dependencies = [ |
| @@ -699,10 +705,19 @@ | |
| 705 | ] |
| 706 | sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } |
| 707 | wheels = [ |
| 708 | { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, |
| 709 | ] |
| 710 | |
| 711 | [[package]] |
| 712 | name = "markdown" |
| 713 | version = "3.10.2" |
| 714 | source = { registry = "https://pypi.org/simple" } |
| 715 | sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } |
| 716 | wheels = [ |
| 717 | { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, |
| 718 | ] |
| 719 | |
| 720 | [[package]] |
| 721 | name = "markdown-it-py" |
| 722 | version = "4.0.0" |
| 723 | source = { registry = "https://pypi.org/simple" } |
| 724 |